├── .eslintrc.cjs ├── .github └── workflows │ └── static.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierrc.cjs ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── docs ├── .vitepress │ ├── config.mts │ └── theme │ │ ├── Layout.vue │ │ ├── Playground.vue │ │ ├── custom.scss │ │ └── index.ts ├── api │ └── index.md ├── auto-imports.d.ts ├── components.d.ts ├── examples │ ├── base │ │ ├── align │ │ │ ├── AlignColumnView.vue │ │ │ ├── AlignView.vue │ │ │ └── index.md │ │ ├── basic │ │ │ ├── BasicView.vue │ │ │ └── index.md │ │ ├── border │ │ │ ├── BorderView.vue │ │ │ └── index.md │ │ ├── checkbox │ │ │ ├── CheckboxCellView.vue │ │ │ ├── CheckboxView.vue │ │ │ └── index.md │ │ ├── empty │ │ │ ├── EmptySlotView.vue │ │ │ ├── EmptyView.vue │ │ │ └── index.md │ │ ├── fixed │ │ │ ├── FixedLeftView.vue │ │ │ ├── FixedRightView.vue │ │ │ ├── FixedView.vue │ │ │ └── index.md │ │ ├── highlight │ │ │ ├── HighlightHoverView.vue │ │ │ ├── HighlightSelectCellView.vue │ │ │ ├── HighlightSelectView.vue │ │ │ ├── SelectionHighlightView.vue │ │ │ ├── SelectionView.vue │ │ │ └── index.md │ │ ├── index-view │ │ │ ├── CustomIndexView.vue │ │ │ ├── IndexView.vue │ │ │ └── index.md │ │ ├── no-header │ │ │ ├── NoHeaderView.vue │ │ │ └── index.md │ │ ├── overflow │ │ │ ├── OverflowHeaderView.vue │ │ │ ├── OverflowView.vue │ │ │ └── index.md │ │ ├── radio │ │ │ ├── RadioCellView.vue │ │ │ ├── RadioView.vue │ │ │ └── index.md │ │ ├── stripe │ │ │ ├── StripeView.vue │ │ │ └── index.md │ │ └── wrap │ │ │ ├── WrapView.vue │ │ │ └── index.md │ ├── cells-3rd │ │ ├── Cells.vue │ │ └── index.md │ ├── cells │ │ ├── Cells.vue │ │ └── index.md │ ├── column │ │ ├── ColumnResize.vue │ │ ├── MinMaxColumn.vue │ │ └── index.md │ ├── custom-class-style │ │ ├── BodyCellView.vue │ │ ├── BodyRowView.vue │ │ ├── HeaderCellView.vue │ │ ├── HeaderRowView.vue │ │ └── index.md │ ├── custom │ │ ├── CustomCell.vue │ │ ├── CustomView.vue │ │ └── index.md │ ├── events │ │ ├── EventsView.vue │ │ └── index.md │ ├── expand │ │ ├── ExpandAllView.vue │ │ ├── ExpandView.vue │ │ └── index.md │ ├── group │ │ ├── GroupView.vue │ │ └── index.md │ ├── index.md │ ├── merge │ │ ├── MergeAndFixedView.vue │ │ ├── MergeHeaderView.vue │ │ ├── body │ │ │ ├── MergeView.vue │ │ │ └── index.md │ │ └── header │ │ │ ├── MergeHeaderAndFixedView.vue │ │ │ └── index.md │ ├── performance │ │ ├── Performance.vue │ │ └── index.md │ ├── spreadsheet │ │ ├── Spreadsheet.vue │ │ └── index.md │ ├── table │ │ ├── ExpandRow.vue │ │ ├── MergeView.vue │ │ ├── TableView.vue │ │ └── index.md │ └── tree │ │ ├── TreeLineView.vue │ │ ├── TreeView.vue │ │ └── index.md ├── guide │ ├── start │ │ ├── QuickStart.vue │ │ └── index.md │ └── theme │ │ └── index.md ├── index.html ├── index.md ├── playground │ └── index.md └── public │ └── favicon.ico ├── env.d.ts ├── package.json ├── pnpm-lock.yaml ├── scripts └── build.ts ├── src ├── components │ └── Placement.vue ├── hooks │ ├── useCalcVisibleColumns.ts │ ├── useEvent │ │ ├── eventEmitter.ts │ │ ├── index.ts │ │ ├── useContentEvent.ts │ │ └── useTableEvent.ts │ └── useResizeColumn │ │ ├── index.scss │ │ └── index.ts ├── index.ts ├── interaction │ ├── scrollZone.ts │ └── selection.ts ├── popper │ ├── popper.scss │ └── popper.ts ├── store │ ├── column.ts │ ├── group.ts │ ├── index.ts │ ├── interaction.ts │ ├── merges.ts │ └── popperStore.ts ├── styles │ ├── index.scss │ └── theme.scss ├── table │ ├── VeriTable.vue │ ├── cell │ │ ├── CheckboxCell.vue │ │ ├── ExpandCell.vue │ │ ├── IndexCell.vue │ │ ├── LinkView.vue │ │ ├── PersonView.vue │ │ ├── RadioCell.vue │ │ ├── TextCell.vue │ │ ├── TreeCell.vue │ │ ├── date │ │ │ ├── DateCover.vue │ │ │ ├── DateDropdown.vue │ │ │ └── DateView.vue │ │ ├── link │ │ │ ├── LinkCover.vue │ │ │ ├── LinkDropdown.vue │ │ │ └── LinkView.vue │ │ └── select │ │ │ ├── SelectCover.vue │ │ │ ├── SelectDropdown.vue │ │ │ └── SelectView.vue │ ├── header │ │ ├── Header.vue │ │ ├── HeaderRow.vue │ │ └── cell │ │ │ ├── HeaderCheckboxCell.vue │ │ │ ├── HeaderIndexCell.vue │ │ │ ├── HeaderOrderCell.vue │ │ │ └── HeaderTextCell.vue │ └── row │ │ ├── BaseRow.tsx │ │ ├── ExpandRow.vue │ │ └── GroupRow.vue ├── template │ ├── GridTable.vue │ └── GridTableColumn.vue ├── type.ts └── utils │ ├── column.ts │ ├── getCellFromEvent.ts │ └── merge.ts ├── todo.md ├── tsconfig.app.json ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.vitest.json └── vitest.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript', 10 | '@vue/eslint-config-prettier/skip-formatting' 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 'latest' 14 | }, 15 | rules: { 16 | 'vue/multi-word-component-names': 'off' 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['master'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: 'pages' 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v4 36 | 37 | # pnpm 38 | - uses: pnpm/action-setup@v3 39 | with: 40 | version: 8 41 | 42 | # install dependency 43 | - run: pnpm install 44 | 45 | # 打包 46 | - run: npm run build:docs 47 | 48 | - name: Upload artifact 49 | uses: actions/upload-pages-artifact@v3 50 | with: 51 | # Upload entire repository 52 | path: './docs/.vitepress/dist' 53 | 54 | - name: Deploy to GitHub Pages 55 | id: deployment 56 | uses: actions/deploy-pages@v4 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | 11 | # Editor directories and files 12 | .idea 13 | *.suo 14 | *.ntvs* 15 | *.njsproj 16 | *.sln 17 | *.sw? 18 | tsconfig.build.tsbuildinfo 19 | 20 | # custom 21 | node_modules 22 | .DS_Store 23 | dist 24 | dist-ssr 25 | coverage 26 | *.local 27 | 28 | /cypress/videos/ 29 | /cypress/screenshots/ 30 | 31 | /docs/.vitepress/cache 32 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .vscode 3 | docs 4 | node_modules/ 5 | scripts/ 6 | .eslintignore 7 | .eslintrc.cjs 8 | .gitignore 9 | .prettierrc.js 10 | tsconfig.json 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry="https://registry.npmjs.org/" -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | $schema: 'https://json.schemastore.org/prettierrc', 3 | semi: true, 4 | tabWidth: 2, 5 | singleQuote: true, 6 | printWidth: 100, 7 | trailingComma: 'all', // 末尾加逗号 默认none es5 包括es5中的数组、对象 all 包括函数对象等所有可选 8 | arrowParens: 'always', // (x) => {} 箭头函数参数只有一个时是否要有小括号。always: 加;avoid: 省略括号 9 | wrapAttributes: true, 10 | sortAttributes: true, // 属性自动排序 11 | }; 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.iconTheme": "material-icon-theme", 3 | "editor.tabSize": 2, 4 | "editor.formatOnPaste": false, // required 5 | "editor.formatOnType": false, // required 6 | "editor.formatOnSave": true, // optional 7 | "editor.formatOnSaveMode": "file", // required to format on save 8 | // "files.autoSave": "onFocusChange", 9 | "editor.defaultFormatter": "esbenp.prettier-vscode", 10 | "[vue]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[javascript]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[typescript]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "[mdx]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[github-actions-workflow]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | 26 | "files.associations": { 27 | "*.vue": "vue", 28 | "*.wpy": "vue", 29 | "*.md": "markdown" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-virt-grid 2 | 3 | 一个同时支持 合并单元格、纵向虚拟列表、横向虚拟列表 的 vue3表格组件。(仍在开发中) 4 | 5 | ## Documentation 6 | 7 | To check out docs, visit vue-virt-grid 8 | 9 | ## 想名字 10 | 11 | veritable 12 | 13 | 14 | 15 | Virtual (虚拟的): xx 16 | Efficient (高效的): 具备高效的数据处理和加载能力,在处理大量数据时,能快速响应操作指令,提升用户工作效率。 17 | Responsive (响应迅速的): 对用户交互操作,如筛选、排序、点击等,能即时给出反馈,拥有流畅的交互体验。 18 | Intuitive (直观的): 界面设计直观易懂,无需复杂学习过程,用户就能轻松掌握表格操作,快速获取所需数据 。 19 | 20 | s-table STable 21 | 22 | x-table XTable 23 | 24 | da-table DaTable 25 | data-table 26 | 27 | portable Portable 28 | 便携式的 29 | 30 | vue-virt-table VirtTable 31 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/Layout.vue: -------------------------------------------------------------------------------- 1 | 9 | 27 | 40 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/Playground.vue: -------------------------------------------------------------------------------- 1 | 10 | 32 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.scss: -------------------------------------------------------------------------------- 1 | body { 2 | overscroll-behavior: none; 3 | } 4 | 5 | :root { 6 | --vp-layout-max-width: 2440px; 7 | } 8 | 9 | .VPDoc.has-aside .content-container { 10 | max-width: 1920px !important; 11 | } 12 | 13 | @media (min-width: 960px) { 14 | .VPNavBar.has-sidebar .wrapper { 15 | padding: 0 32px !important; 16 | } 17 | 18 | .VPNavBar.has-sidebar .content { 19 | padding-left: var(--vp-sidebar-width) !important; 20 | padding-right: 32px !important; 21 | } 22 | } 23 | 24 | @media (min-width: 1440px) { 25 | .VPSidebar { 26 | width: var(--vp-sidebar-width) !important; 27 | padding-left: 32px !important; 28 | } 29 | .Layout .VPContent.has-sidebar { 30 | padding-left: var(--vp-sidebar-width) !important; 31 | } 32 | 33 | .VPNavBar.has-sidebar div.title { 34 | width: var(--vp-sidebar-width) !important; 35 | padding: 0 32px !important; 36 | } 37 | } 38 | 39 | // 用来初始化table样式 40 | .page-examples { 41 | table { 42 | display: unset; 43 | border-collapse: unset; 44 | margin: unset; 45 | overflow-x: unset; 46 | } 47 | 48 | tr { 49 | background-color: unset; 50 | border-top: unset; 51 | transition: unset; 52 | } 53 | 54 | tr:nth-child(2n) { 55 | background-color: unset; 56 | } 57 | 58 | th, 59 | td { 60 | border: unset; 61 | padding: unset; 62 | } 63 | 64 | th { 65 | text-align: unset; 66 | font-size: unset; 67 | font-weight: unset; 68 | color: unset; 69 | background-color: unset; 70 | } 71 | 72 | td { 73 | font-size: 14px; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { type Theme, useRoute } from 'vitepress'; 2 | import DefaultTheme from 'vitepress/theme'; 3 | import Layout from './Layout.vue'; 4 | import './custom.scss'; 5 | import 'resize-observer-polyfill/dist/ResizeObserver.global'; 6 | 7 | export default { 8 | extends: DefaultTheme, 9 | Layout, 10 | } satisfies Theme; 11 | -------------------------------------------------------------------------------- /docs/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /docs/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | export {} 6 | 7 | /* prettier-ignore */ 8 | declare module 'vue' { 9 | export interface GlobalComponents { 10 | RouterLink: typeof import('vue-router')['RouterLink'] 11 | RouterView: typeof import('vue-router')['RouterView'] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/examples/base/align/AlignColumnView.vue: -------------------------------------------------------------------------------- 1 | 22 | 63 | 75 | -------------------------------------------------------------------------------- /docs/examples/base/align/AlignView.vue: -------------------------------------------------------------------------------- 1 | 22 | 57 | 69 | -------------------------------------------------------------------------------- /docs/examples/base/align/index.md: -------------------------------------------------------------------------------- 1 | # 对齐方式 2 | 3 | ## 全局对齐方式 4 | 5 | ```ts 6 | interface Options { 7 | align: 'left' | 'center' | 'right'; 8 | headerAlign: 'left' | 'center' | 'right'; 9 | 10 | verticalAlign: 'top' | 'middle' | 'bottom'; 11 | headerVerticalAlign: 'top' | 'middle' | 'bottom'; 12 | } 13 | ``` 14 | 15 | 2 |
3 |
4 | 5 |
6 |
7 | 8 | 37 | 48 | -------------------------------------------------------------------------------- /docs/examples/base/basic/index.md: -------------------------------------------------------------------------------- 1 | # 基础示例 2 | 3 | 2 |
3 |
4 | 11 |
12 |
13 | 14 | 43 | 54 | -------------------------------------------------------------------------------- /docs/examples/base/border/index.md: -------------------------------------------------------------------------------- 1 | # 带边框表格(TODO: 支持一个外边框 border=outer) 2 | 3 | ```ts 4 | interface Options { 5 | border: boolean; 6 | } 7 | ``` 8 | 9 | 2 |
3 |
4 | 11 |
12 |
13 | 14 | 45 | 56 | -------------------------------------------------------------------------------- /docs/examples/base/checkbox/CheckboxView.vue: -------------------------------------------------------------------------------- 1 | 15 | 43 | 55 | -------------------------------------------------------------------------------- /docs/examples/base/checkbox/index.md: -------------------------------------------------------------------------------- 1 | # 复选框 2 | 3 | ## 复选框 4 | 5 | 2 |
3 |
4 | 5 | 6 | 7 |
8 |
9 | 10 | 24 | 35 | -------------------------------------------------------------------------------- /docs/examples/base/empty/EmptyView.vue: -------------------------------------------------------------------------------- 1 | 8 | 22 | 33 | -------------------------------------------------------------------------------- /docs/examples/base/empty/index.md: -------------------------------------------------------------------------------- 1 | # 空态 2 | 3 | ## 默认空态 4 | 5 | 2 |
3 |
4 | 12 |
13 |
14 | 15 | 47 | 58 | -------------------------------------------------------------------------------- /docs/examples/base/fixed/FixedRightView.vue: -------------------------------------------------------------------------------- 1 | 15 | 47 | 58 | -------------------------------------------------------------------------------- /docs/examples/base/fixed/FixedView.vue: -------------------------------------------------------------------------------- 1 | 15 | 49 | 60 | -------------------------------------------------------------------------------- /docs/examples/base/fixed/index.md: -------------------------------------------------------------------------------- 1 | # 冻结列 2 | 3 | ## 左侧-冻结列 4 | 5 | 2 |
3 |
4 | 12 |
13 |
14 | 15 | 43 | 54 | -------------------------------------------------------------------------------- /docs/examples/base/highlight/HighlightSelectCellView.vue: -------------------------------------------------------------------------------- 1 | 17 | 45 | 56 | -------------------------------------------------------------------------------- /docs/examples/base/highlight/HighlightSelectView.vue: -------------------------------------------------------------------------------- 1 | 15 | 43 | 54 | -------------------------------------------------------------------------------- /docs/examples/base/highlight/SelectionHighlightView.vue: -------------------------------------------------------------------------------- 1 | 16 | 44 | 55 | -------------------------------------------------------------------------------- /docs/examples/base/highlight/SelectionView.vue: -------------------------------------------------------------------------------- 1 | 17 | 146 | 157 | -------------------------------------------------------------------------------- /docs/examples/base/highlight/index.md: -------------------------------------------------------------------------------- 1 | # 高亮 2 | 3 | > 行列高亮区分3种: 4 | > 5 | > 1. 鼠标悬浮行列(hover) 6 | > 2. 选中行列(select) 7 | > 3. 区域选中(selection) 8 | 9 | ## 悬浮行(hover) 10 | 11 | 2 |
3 | 4 | 5 |
6 | 14 |
15 |
16 | 17 | 47 | 59 | -------------------------------------------------------------------------------- /docs/examples/base/index-view/IndexView.vue: -------------------------------------------------------------------------------- 1 | 17 | 48 | 60 | -------------------------------------------------------------------------------- /docs/examples/base/index-view/index.md: -------------------------------------------------------------------------------- 1 | # 索引 2 | 3 | ## 常规索引 4 | 5 | ```ts 6 | const columns = [ 7 | { 8 | type: 'index', 9 | }, 10 | ]; 11 | ``` 12 | 13 | `x-${index}`, 22 | }, 23 | ]; 24 | ``` 25 | 26 | 2 |
3 |
4 | 5 |
6 |
7 | 8 | 36 | 47 | -------------------------------------------------------------------------------- /docs/examples/base/no-header/index.md: -------------------------------------------------------------------------------- 1 | # 无表头 2 | 3 | ```ts 4 | interface Options { 5 | showHeader: boolean; 6 | } 7 | ``` 8 | 9 | 2 |
3 |
4 | 5 |
6 |
7 | 8 | 38 | 49 | -------------------------------------------------------------------------------- /docs/examples/base/overflow/OverflowView.vue: -------------------------------------------------------------------------------- 1 | 8 | 38 | 49 | -------------------------------------------------------------------------------- /docs/examples/base/overflow/index.md: -------------------------------------------------------------------------------- 1 | # 溢出隐藏(TODO: tooltip 还没实现) 2 | 3 | ## body-cell 4 | 5 | ```ts 6 | // ellipsis 当内容溢出时显示为省略号 7 | // title [推荐!性能好!] 当内容溢出时显示为省略号并用原生 title 显示 8 | // tooltip 当内容溢出时显示为省略号并用 tooltip 显示 9 | interface Options { 10 | textOverflow: 'ellipsis' | 'title' | 'tooltip'; 11 | } 12 | ``` 13 | 14 | 2 |
3 |
4 | 11 |
12 |
13 | 14 | 45 | 56 | -------------------------------------------------------------------------------- /docs/examples/base/radio/RadioView.vue: -------------------------------------------------------------------------------- 1 | 14 | 45 | 56 | -------------------------------------------------------------------------------- /docs/examples/base/radio/index.md: -------------------------------------------------------------------------------- 1 | # 单选框 2 | 3 | ## 单选框 4 | 5 | 2 |
3 |
4 | 11 |
12 |
13 | 14 | 42 | 53 | -------------------------------------------------------------------------------- /docs/examples/base/stripe/index.md: -------------------------------------------------------------------------------- 1 | # 斑马纹表格 2 | 3 | ```ts 4 | interface Options { 5 | stripe: true; 6 | } 7 | ``` 8 | 9 | 2 |
3 |
4 | 5 |
6 |
7 | 8 | 39 | 50 | -------------------------------------------------------------------------------- /docs/examples/base/wrap/index.md: -------------------------------------------------------------------------------- 1 | # 自动换行 2 | 3 | > 默认情况下单元格内容是自动换行的 4 | 5 | 2 |
3 |
4 | 11 |
12 |
13 | 14 | 43 | -------------------------------------------------------------------------------- /docs/examples/column/MinMaxColumn.vue: -------------------------------------------------------------------------------- 1 | 6 | 37 | -------------------------------------------------------------------------------- /docs/examples/column/index.md: -------------------------------------------------------------------------------- 1 | # 列宽拖拽 2 | 3 | ## 开启列宽拖拽 4 | 5 | ```ts 6 | interface Column { 7 | resizable: boolean; 8 | } 9 | ``` 10 | 11 | 2 |
3 |
4 | 12 |
13 |
14 | 15 | 60 | 79 | -------------------------------------------------------------------------------- /docs/examples/custom-class-style/BodyRowView.vue: -------------------------------------------------------------------------------- 1 | 15 | 60 | 79 | -------------------------------------------------------------------------------- /docs/examples/custom-class-style/HeaderCellView.vue: -------------------------------------------------------------------------------- 1 | 15 | 54 | 69 | -------------------------------------------------------------------------------- /docs/examples/custom-class-style/HeaderRowView.vue: -------------------------------------------------------------------------------- 1 | 15 | 51 | 66 | -------------------------------------------------------------------------------- /docs/examples/custom-class-style/index.md: -------------------------------------------------------------------------------- 1 | # 自定义类/样式 2 | 3 | ## 表头行自定义 4 | 5 | 2 |
test
3 | 4 | 11 | -------------------------------------------------------------------------------- /docs/examples/custom/CustomView.vue: -------------------------------------------------------------------------------- 1 | 15 | 70 | 81 | -------------------------------------------------------------------------------- /docs/examples/custom/index.md: -------------------------------------------------------------------------------- 1 | # 自定义 2 | 3 | 2 |
3 |
4 | 23 |
24 |
25 | 26 | 108 | 119 | -------------------------------------------------------------------------------- /docs/examples/events/index.md: -------------------------------------------------------------------------------- 1 | # 事件 2 | 3 | 2 |
3 |
4 | 11 |
12 |
13 | 14 | 102 | 113 | -------------------------------------------------------------------------------- /docs/examples/expand/ExpandView.vue: -------------------------------------------------------------------------------- 1 | 8 | 93 | 104 | -------------------------------------------------------------------------------- /docs/examples/expand/index.md: -------------------------------------------------------------------------------- 1 | # 展开行 2 | 3 | ## 基础示例 4 | 5 | 2 |
3 |
4 | 13 |
14 |
15 | 16 | 207 | -------------------------------------------------------------------------------- /docs/examples/merge/MergeHeaderView.vue: -------------------------------------------------------------------------------- 1 | 15 | 216 | 227 | -------------------------------------------------------------------------------- /docs/examples/merge/body/index.md: -------------------------------------------------------------------------------- 1 | # 合并单元格 2 | 3 | ## 表身合并 4 | 5 | 2 |
3 |
4 | 14 |
15 |
16 | 17 | 78 | 89 | -------------------------------------------------------------------------------- /docs/examples/spreadsheet/index.md: -------------------------------------------------------------------------------- 1 | # Spreadsheet 2 | 3 | 2 | 10 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 59 | 60 | -------------------------------------------------------------------------------- /docs/examples/table/MergeView.vue: -------------------------------------------------------------------------------- 1 | 10 | 58 | -------------------------------------------------------------------------------- /docs/examples/table/TableView.vue: -------------------------------------------------------------------------------- 1 | 24 | 58 | 59 | -------------------------------------------------------------------------------- /docs/examples/table/index.md: -------------------------------------------------------------------------------- 1 | # template 2 | 3 | 使用template模版方式,类似于自定义渲染,所以所有单元格都不会有默认样式中的padding,用户需要自行设置样式或者使用`vue-virt-grid-cell`的类名 4 | 5 | ## 自定义插槽 6 | 7 | 2 |
3 |
4 | 13 |
14 |
15 | 16 | 123 | 134 | -------------------------------------------------------------------------------- /docs/examples/tree/TreeView.vue: -------------------------------------------------------------------------------- 1 | 15 | 121 | 132 | -------------------------------------------------------------------------------- /docs/examples/tree/index.md: -------------------------------------------------------------------------------- 1 | # 树形结构(TODO: 提示线有样式bug) 2 | 3 | ## 基础示例 4 | 5 | 2 | 3 | 4 | 63 | -------------------------------------------------------------------------------- /docs/guide/start/index.md: -------------------------------------------------------------------------------- 1 | # 开始使用 2 | 3 | ## 安装 4 | 5 | ::: code-group 6 | 7 | ```sh [npm] 8 | $ npm add vue-virt-grid 9 | ``` 10 | 11 | ```sh [pnpm] 12 | $ pnpm add vue-virt-grid 13 | ``` 14 | 15 | ```sh [yarn] 16 | $ yarn add vue-virt-grid 17 | ``` 18 | 19 | ::: 20 | 21 | ## 依赖 22 | 23 | - `"vue": ">=3.0.0"` 24 | 25 | ## Quick start 26 | 27 | 30 | .vp-doc a { 31 | text-decoration-style: dotted; 32 | text-underline-offset: 4px; 33 | } 34 | .vp-doc a:hover { 35 | text-decoration-style: solid; 36 | } 37 | 38 | -------------------------------------------------------------------------------- /docs/guide/theme/index.md: -------------------------------------------------------------------------------- 1 | # 主题 2 | 3 | ## 白天模式 4 | 5 | ```scss 6 | // 表格元素层级 7 | --vue-virt-grid-index-normal: 1; 8 | // 表格边框颜色 9 | --vue-virt-grid-border-color: #ebeef5; 10 | // 表格边框 11 | --vue-virt-grid-border: 1px solid var(--vue-virt-grid-border-color); 12 | // 表格字体颜色 13 | --vue-virt-grid-text-color: #606266; 14 | // 表头字体颜色 15 | --vue-virt-grid-header-text-color: #909399; 16 | // 表格行hover高亮背景 17 | --vue-virt-grid-row-hover-bg-color: #f5f7fa; 18 | // 表格行斑马纹高亮背景 19 | --vue-virt-grid-row-stripe-bg-color: #fafafa; 20 | // 行选中背景 21 | --vue-virt-grid-current-row-bg-color: #fff7e6; 22 | // 列选中背景 23 | --vue-virt-grid-current-column-bg-color: #fff7e6; 24 | // 表头背景 25 | --vue-virt-grid-header-bg-color: #ccc; 26 | // 表格固定列阴影 27 | --vue-virt-grid-fixed-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12); 28 | // 表格背景颜色 29 | --vue-virt-grid-bg-color: #ffffff; 30 | // 表格行背景颜色 31 | --vue-virt-grid-tr-bg-color: #ffffff; 32 | // 表格展开列背景颜色 33 | --vue-virt-grid-expanded-cell-bg-color: #ffffff; 34 | // 左固定列边框样式 35 | --vue-virt-grid-fixed-left-column: inset 10px 0 10px -10px rgba(0, 0, 0, 0.15); 36 | // 右固定列边框样式 37 | --vue-virt-grid-fixed-right-column: inset -10px 0 10px -10px rgba(0, 0, 0, 0.15); 38 | // 索引列层级 39 | --vue-virt-grid-index: var(--vue-virt-grid-index-normal); 40 | // 区域选中边框样式 41 | --vue-virt-grid-select-border-color: #409eff; 42 | // 区域选中背景 43 | --vue-virt-grid-select-bg-color: #ecf5ff; 44 | // 滚动条滑块颜色 45 | --vue-virt-grid-scrollbar-thumb-color: rgba(182, 185, 192, 0.3); 46 | // 滚动条滑块hover颜色 47 | --vue-virt-grid-scrollbar-thumb-color-hover: rgba(182, 185, 192, 0.5); 48 | // 树状连线颜色 49 | --vue-virt-grid-tree-line-color: #303133; 50 | ``` 51 | 52 | ## 暗夜模式 53 | 54 | ```scss 55 | // 表格边框颜色 56 | --vue-virt-grid-border-color: #363637; 57 | // 表格边框 58 | --vue-virt-grid-border: 1px solid var(--vue-virt-grid-border-color); 59 | // 表格字体颜色 60 | --vue-virt-grid-text-color: #cfd3dc; 61 | // 表头字体颜色 62 | --vue-virt-grid-header-text-color: #a3a6ad; 63 | // 表格行hover高亮背景 64 | --vue-virt-grid-row-hover-bg-color: #262727; 65 | // 表格行斑马纹高亮背景 66 | --vue-virt-grid-row-stripe-bg-color: #1d1d1d; 67 | // 行选中背景 68 | --vue-virt-grid-current-row-bg-color: #2b2211; 69 | // 行选中背景 70 | --vue-virt-grid-current-column-bg-color: #2b2211; 71 | // 表头背景 72 | --vue-virt-grid-header-bg-color: #141414; 73 | // 表格固定列阴影 74 | --vue-virt-grid-fixed-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.72); 75 | // 表格背景颜色 76 | --vue-virt-grid-bg-color: #141414; 77 | // 表格行背景颜色 78 | --vue-virt-grid-tr-bg-color: #141414; 79 | // 表格展开列背景颜色 80 | --vue-virt-grid-expanded-cell-bg-color: #141414; 81 | // 左固定列边框样式 82 | --vue-virt-grid-fixed-left-column: inset 10px 0 10px -10px rgba(0, 0, 0, 0.15); 83 | // 右固定列边框样式 84 | --vue-virt-grid-fixed-right-column: inset -10px 0 10px -10px rgba(0, 0, 0, 0.15); 85 | // 索引列层级 86 | --vue-virt-grid-index: var(--vue-virt-grid-index-normal); 87 | // 区域选中边框样式 88 | --vue-virt-grid-select-border-color: #409eff; 89 | // 区域选中背景 90 | --vue-virt-grid-select-bg-color: #18222c; 91 | // 滚动条滑块颜色 92 | --vue-virt-grid-scrollbar-thumb-color: rgba(163, 166, 173, 0.3); 93 | // 滚动条滑块hover颜色 94 | --vue-virt-grid-scrollbar-thumb-color-hover: rgba(163, 166, 173, 0.5); 95 | // 树状连线颜色 96 | --vue-virt-grid-tree-line-color: #4c4d4f; 97 | ``` 98 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vue-virt-gird 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: 'vue-virt-grid' 7 | text: '一个vue3表格组件' 8 | tagline: '同时支持: 横向虚拟列表渲染、纵向虚拟列表渲染、合并单元格' 9 | actions: 10 | 11 | features: 12 | - title: 特性A 13 | details: 横向虚拟渲染 14 | - title: 特性B 15 | details: 纵向虚拟渲染 16 | - title: 特性C 17 | details: 合并单元格 18 | - title: 特性D 19 | details: 支持基础表格Api 20 | --- 21 | -------------------------------------------------------------------------------- /docs/playground/index.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolarorz/vue-virt-grid/ebd9b780a3ba6ae38a785cdf3ed81d01540ddf3d/docs/playground/index.md -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolarorz/vue-virt-grid/ebd9b780a3ba6ae38a785cdf3ed81d01540ddf3d/docs/public/favicon.ico -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-virt-grid", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "main": "lib/index.js", 6 | "module": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "author": "kolarorz", 9 | "contributors": [], 10 | "scripts": { 11 | "dev": "vitepress dev docs --host --port=5222", 12 | "build:docs": "vitepress build docs", 13 | "build": "vite build -c ./scripts/build.ts && vue-tsc -p tsconfig.build.json", 14 | "lint": "eslint ./docs --ext .vue,.ts,.tsx,.js,.jsx --fix" 15 | }, 16 | "peerDependencies": { 17 | "vue": ">=3.0.0" 18 | }, 19 | "dependencies": { 20 | "lodash-es": "^4.17.21", 21 | "nanoid": "^5.0.4", 22 | "vue-virt-list": "^1.5.8" 23 | }, 24 | "devDependencies": { 25 | "@rushstack/eslint-patch": "^1.3.3", 26 | "@tsconfig/node18": "^18.2.2", 27 | "@types/jsdom": "^21.1.3", 28 | "@types/lodash-es": "^4.17.12", 29 | "@types/node": "^18.18.5", 30 | "@vitejs/plugin-vue": "^4.4.0", 31 | "@vitejs/plugin-vue-jsx": "^3.1.0", 32 | "@vue/eslint-config-prettier": "^8.0.0", 33 | "@vue/eslint-config-typescript": "^12.0.0", 34 | "@vue/repl": "^3.4.0", 35 | "@vue/test-utils": "^2.4.1", 36 | "@vue/tsconfig": "^0.4.0", 37 | "element-plus": "^2.9.1", 38 | "eslint": "^8.49.0", 39 | "eslint-plugin-vue": "^9.17.0", 40 | "jsdom": "^22.1.0", 41 | "markdown-it-container": "^4.0.0", 42 | "npm-run-all2": "^6.1.1", 43 | "prettier": "^3.0.3", 44 | "resize-observer-polyfill": "^1.5.1", 45 | "sass": "^1.69.5", 46 | "typescript": "~5.2.0", 47 | "unplugin-auto-import": "^0.19.0", 48 | "unplugin-element-plus": "^0.9.0", 49 | "unplugin-vue-components": "^0.28.0", 50 | "vite": "^4.4.11", 51 | "vitepress": "1.5.0", 52 | "vitest": "^0.34.6", 53 | "vue": "^3.3.13", 54 | "vue-router": "^4.2.5", 55 | "vue-tsc": "^1.8.19" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | 3 | import { defineConfig } from 'vite'; 4 | // import legacy from '@vitejs/plugin-legacy'; 5 | import vue from '@vitejs/plugin-vue'; 6 | import vueJsx from '@vitejs/plugin-vue-jsx'; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ command }) => ({ 10 | plugins: [vue(), vueJsx()], 11 | build: { 12 | emptyOutDir: true, 13 | minify: false, 14 | sourcemap: true, 15 | target: ['chrome62'], 16 | 17 | rollupOptions: { 18 | external: ['vue', 'vue-demi'], 19 | preserveEntrySignatures: 'strict', 20 | 21 | input: './src/index.ts', 22 | output: { 23 | manualChunks: undefined, 24 | format: 'esm', 25 | dir: './lib', 26 | // preserveModules: true, 27 | entryFileNames: '[name].js', 28 | assetFileNames: '[name][extname]', 29 | // intro: 'import "./index.css";' 30 | }, 31 | }, 32 | }, 33 | resolve: { 34 | alias: { 35 | '@': fileURLToPath(new URL('../', import.meta.url)), 36 | }, 37 | }, 38 | })); 39 | -------------------------------------------------------------------------------- /src/components/Placement.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /src/hooks/useCalcVisibleColumns.ts: -------------------------------------------------------------------------------- 1 | export const useCalcVisibleColumns = () => {}; 2 | -------------------------------------------------------------------------------- /src/hooks/useEvent/eventEmitter.ts: -------------------------------------------------------------------------------- 1 | type EventListener = (...args: any[]) => void; 2 | 3 | export class EventEmitter { 4 | private events: Record = {}; 5 | 6 | // 订阅事件 7 | on(event: string, listener: EventListener): void { 8 | if (!this.events[event]) { 9 | this.events[event] = []; 10 | } 11 | this.events[event].push(listener); 12 | } 13 | 14 | // 取消订阅事件 15 | off(event: string, listener: EventListener): void { 16 | if (!this.events[event]) return; 17 | this.events[event] = this.events[event].filter((l) => l !== listener); 18 | } 19 | 20 | // 发布事件 21 | emit(event: string, ...args: any[]): void { 22 | if (!this.events[event]) return; 23 | this.events[event].forEach((listener) => listener(...args)); 24 | } 25 | 26 | // 订阅一次性事件 27 | once(event: string, listener: EventListener): void { 28 | const onceWrapper = (...args: any[]) => { 29 | listener(...args); 30 | this.off(event, onceWrapper); 31 | }; 32 | this.on(event, onceWrapper); 33 | } 34 | 35 | offAll(): void { 36 | this.events = {}; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/hooks/useEvent/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useTableEvent'; 2 | export * from './useContentEvent'; 3 | export * from './eventEmitter'; 4 | -------------------------------------------------------------------------------- /src/hooks/useEvent/useContentEvent.ts: -------------------------------------------------------------------------------- 1 | import type { GridStore } from '@/src/store'; 2 | import { CellEventEnum, RowEventEnum, HeaderEventEnum, type Column } from '@/src/type'; 3 | 4 | /** 5 | * @desc 检查并获取表头信息 6 | */ 7 | const checkAndGetThInfo = (e: MouseEvent, gridStore: GridStore) => { 8 | const composedPath = e.composedPath(); 9 | const thEl = composedPath.find((el) => (el as HTMLElement).classList?.contains('vtg-th')); 10 | 11 | if (thEl) { 12 | const colId = (thEl as HTMLElement).dataset.id; 13 | if (colId === undefined) return; 14 | const targetColumnData = gridStore.columnModule.columnsInfo.headerCellInfo[colId]; 15 | return { 16 | event: e, 17 | column: targetColumnData, 18 | }; 19 | } 20 | return null; 21 | }; 22 | 23 | /** 24 | * @desc 检查并获取单元格信息 25 | */ 26 | const checkAndGetTdInfo = (event: MouseEvent, gridStore: GridStore) => { 27 | const composedPath = event.composedPath(); 28 | const tdEl = composedPath.find((el) => 29 | (el as HTMLElement).classList?.contains('vtg-td'), 30 | ) as HTMLElement; 31 | 32 | if (tdEl) { 33 | const rowIdx = (tdEl as HTMLElement).dataset.rowidx; 34 | const colIdx = (tdEl as HTMLElement).dataset.colidx; 35 | if (rowIdx === undefined || colIdx === undefined) return; 36 | const rowIdxNum = +rowIdx; 37 | const colIdxNum = +colIdx; 38 | const targetColumn = gridStore.columnModule.flattedColumns[colIdxNum]; 39 | const targetRow = gridStore.originList[rowIdxNum]; 40 | if (targetColumn && targetRow) { 41 | return { 42 | event, 43 | column: targetColumn, 44 | columnIndex: colIdxNum, 45 | row: gridStore.originList[rowIdxNum], 46 | rowIndex: rowIdxNum, 47 | cell: targetColumn.field ? targetRow[targetColumn.field] : null, 48 | rect: tdEl.getBoundingClientRect(), 49 | el: tdEl, 50 | }; 51 | } 52 | return null; 53 | } 54 | }; 55 | 56 | /** 57 | * @desc 表格内容区域相关事件,包含单元格、行、表头 58 | */ 59 | export const useContentEvent = (gridStore: GridStore) => { 60 | const onClick = (e: MouseEvent) => { 61 | const thData = checkAndGetThInfo(e, gridStore); 62 | if (thData) { 63 | gridStore.eventEmitter.emit(HeaderEventEnum.HeaderClick, thData); 64 | return; 65 | } 66 | const tdData = checkAndGetTdInfo(e, gridStore); 67 | if (tdData) { 68 | gridStore.eventEmitter.emit(CellEventEnum.CellClick, tdData); 69 | gridStore.eventEmitter.emit(RowEventEnum.RowClick, tdData); 70 | } 71 | }; 72 | const onDblclick = (e: MouseEvent) => { 73 | e.preventDefault(); 74 | e.stopPropagation(); 75 | 76 | const thData = checkAndGetThInfo(e, gridStore); 77 | if (thData) { 78 | gridStore.eventEmitter.emit(HeaderEventEnum.HeaderDblclick, thData); 79 | return; 80 | } 81 | const tdData = checkAndGetTdInfo(e, gridStore); 82 | if (tdData) { 83 | gridStore.eventEmitter.emit(CellEventEnum.CellDblclick, tdData); 84 | gridStore.eventEmitter.emit(RowEventEnum.RowDblclick, tdData); 85 | 86 | // 双击 87 | // console.log('dblclick', tdData); 88 | } 89 | }; 90 | const onContextmenu = (e: MouseEvent) => { 91 | const thData = checkAndGetThInfo(e, gridStore); 92 | if (thData) { 93 | gridStore.eventEmitter.emit(HeaderEventEnum.HeaderContextmenu, thData); 94 | return; 95 | } 96 | const tdData = checkAndGetTdInfo(e, gridStore); 97 | if (tdData) { 98 | gridStore.eventEmitter.emit(CellEventEnum.CellContextmenu, tdData); 99 | gridStore.eventEmitter.emit(RowEventEnum.RowContextmenu, tdData); 100 | } 101 | }; 102 | const onMouseDown = (e: MouseEvent) => { 103 | const path = e.composedPath() as HTMLElement[]; 104 | // const targetTr = path.find((el) => el.tagName === 'TR'); 105 | const targetTd = path.find((el) => el.tagName === 'TD'); 106 | // console.log(targetTr, targetTr?.dataset.id); 107 | // console.log(targetTd, targetTd?.dataset.rowidx, targetTd?.dataset.colidx); 108 | 109 | if (targetTd?.dataset.rowidx !== undefined) { 110 | gridStore.interactionModule.setSelectRow(Number(targetTd?.dataset.rowidx)); 111 | } 112 | if (targetTd?.dataset.colidx !== undefined) { 113 | gridStore.interactionModule.setSelectCol(Number(targetTd?.dataset.colidx)); 114 | } 115 | gridStore.popperModule.remove(); 116 | const tdData = checkAndGetTdInfo(e, gridStore); 117 | gridStore.popperModule.coverRender(tdData); 118 | }; 119 | 120 | // 添加一个全局的事件监听,用于删除弹出层和选中单元格 121 | document.addEventListener('mousedown', (evt: MouseEvent) => { 122 | const popper = (evt.composedPath() as HTMLElement[]).find( 123 | (el: HTMLElement) => 124 | el?.classList?.contains('vtg-popper-container') || 125 | el?.classList?.contains('vtg-td') || 126 | // TODO 未来用插件化支持 127 | el?.classList?.contains('el-popper'), 128 | ); 129 | if (!popper) { 130 | gridStore.interactionModule.clearSelect(); 131 | gridStore.popperModule.remove(); 132 | // TODO 未来用插件化支持 133 | document.querySelectorAll('.el-popper').forEach((el) => el.remove()); 134 | } 135 | }); 136 | 137 | return { 138 | onClick, 139 | onDblclick, 140 | onContextmenu, 141 | onMouseDown, 142 | }; 143 | }; 144 | -------------------------------------------------------------------------------- /src/hooks/useEvent/useTableEvent.ts: -------------------------------------------------------------------------------- 1 | import type { GridStore } from '@/src/store'; 2 | import { TableEventEnum, type SelectedCells } from '@/src/type'; 3 | 4 | export const useTableEvent = (gridStore: GridStore) => { 5 | return { 6 | onExpandChange(data: { row: any; expandedRows: any[] }) { 7 | gridStore.eventEmitter.emit(TableEventEnum.ExpandChange, data); 8 | }, 9 | onCellSelection(data: { areas: SelectedCells[][]; cells: SelectedCells[] }) { 10 | gridStore.eventEmitter.emit(TableEventEnum.BoxSelection, data); 11 | }, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/hooks/useResizeColumn/index.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/theme.scss'; 2 | 3 | .gird-column-resize-trigger { 4 | position: absolute; 5 | width: 4px; 6 | right: 0; 7 | top: 0; 8 | bottom: 0; 9 | cursor: col-resize; 10 | display: inline-block; 11 | 12 | &.is-opacity { 13 | &::after { 14 | content: ''; 15 | position: absolute; 16 | top: 50%; 17 | right: 0; 18 | height: 50%; 19 | transform: translateY(-50%); 20 | background-color: var(--#{$prefix}-border-color); 21 | display: inline-block; 22 | width: 1px; 23 | } 24 | } 25 | } 26 | 27 | .gird-column-resize-line { 28 | pointer-events: none; 29 | position: absolute; 30 | top: 0; 31 | bottom: 0; 32 | left: 0; 33 | display: inline-block; 34 | width: 2px; 35 | background-color: var(--#{$prefix}-select-border-color); 36 | z-index: 100; 37 | } 38 | -------------------------------------------------------------------------------- /src/hooks/useResizeColumn/index.ts: -------------------------------------------------------------------------------- 1 | import { onUpdated, watch } from 'vue'; 2 | import './index.scss'; 3 | import type { ColumnItem } from '@/src/type'; 4 | import { clamp } from 'lodash-es'; 5 | 6 | const ColumnResizeLineClass = 'gird-column-resize-line'; 7 | 8 | const ColumnResizeTriggerClass = 'gird-column-resize-trigger'; 9 | 10 | function setRelative(el: HTMLElement) { 11 | const style = getComputedStyle(el); 12 | const position = style.getPropertyValue('position'); 13 | if (!position || position === 'static') { 14 | el.style.position = 'relative'; 15 | } 16 | } 17 | 18 | function isInitialized(el: HTMLElement) { 19 | return !!(el as any).__resizeTrigger; 20 | } 21 | 22 | // 全局唯一,防止重复创建 23 | let resizeLine: HTMLElement | undefined; 24 | 25 | export const clearResizeLine = () => { 26 | if (!resizeLine) return; 27 | resizeLine.remove(); 28 | resizeLine = undefined; 29 | }; 30 | 31 | export function useResizeColumn( 32 | columnEl: HTMLElement, 33 | headerInfo: ColumnItem, 34 | tableEl: HTMLElement, 35 | cb: (width: number) => void, 36 | ) { 37 | if (isInitialized(columnEl)) return; 38 | const resizeTriggerDom = document.createElement('div'); 39 | resizeTriggerDom.className = ColumnResizeTriggerClass; 40 | const data = { 41 | resizing: false, 42 | startX: 0, 43 | endX: 0, 44 | columnLeft: 0, 45 | columnCurrentWidth: 0, 46 | }; 47 | 48 | function getWillWidth() { 49 | return clamp( 50 | data.endX - data.startX + data.columnCurrentWidth, 51 | headerInfo.minWidth ?? 0, 52 | headerInfo.maxWidth ?? Infinity, 53 | ); 54 | } 55 | 56 | function setupTrigger() { 57 | if (!headerInfo?.resizable) { 58 | resizeTriggerDom.parentElement?.removeChild(resizeTriggerDom); 59 | return; 60 | } 61 | if (columnEl && !columnEl.contains(resizeTriggerDom)) { 62 | columnEl.appendChild(resizeTriggerDom); 63 | setRelative(columnEl); 64 | 65 | const style = getComputedStyle(columnEl); 66 | if (style.getPropertyValue('border-right-width') === '0px') { 67 | resizeTriggerDom.classList.add('is-opacity'); 68 | } 69 | } 70 | (columnEl as any).__resizeTrigger = true; 71 | } 72 | onUpdated(() => setupTrigger); 73 | watch(() => [columnEl, headerInfo], setupTrigger, { immediate: true }); 74 | 75 | // let resizeLine: HTMLElement | undefined; 76 | 77 | function setupResizeLine(e: MouseEvent) { 78 | if (!tableEl) return; 79 | data.startX = e.clientX; 80 | data.columnLeft = 81 | tableEl.scrollLeft + 82 | columnEl.getBoundingClientRect().left - 83 | tableEl.getBoundingClientRect().x - 84 | 2; 85 | data.endX = e.clientX; 86 | data.columnCurrentWidth = columnEl.getBoundingClientRect().width; 87 | 88 | resizeLine = document.createElement('div'); 89 | resizeLine.className = ColumnResizeLineClass; 90 | tableEl.appendChild(resizeLine); 91 | setRelative(tableEl); 92 | resizeLine.style.transform = `translateX(${getWillWidth() + data.columnLeft}px)`; 93 | resizeLine.style.top = `${tableEl.scrollTop}px`; 94 | } 95 | 96 | function resize(e: MouseEvent) { 97 | if (!data.resizing) return; 98 | e.preventDefault(); 99 | data.endX = e.clientX; 100 | if (resizeLine) 101 | resizeLine.style.transform = `translateX(${getWillWidth() + data.columnLeft}px)`; 102 | } 103 | 104 | resizeTriggerDom.addEventListener('mouseenter', (e) => { 105 | if (!resizeLine) setupResizeLine(e); 106 | }); 107 | 108 | resizeTriggerDom.addEventListener('mouseleave', (e) => { 109 | if (!data.resizing && resizeLine) { 110 | resizeLine.remove(); 111 | resizeLine = undefined; 112 | } 113 | }); 114 | 115 | resizeTriggerDom.addEventListener('mousedown', (e) => { 116 | e.preventDefault(); 117 | if (data.resizing) return; 118 | data.resizing = true; 119 | 120 | document.addEventListener('mousemove', resize); 121 | document.addEventListener( 122 | 'mouseup', 123 | () => { 124 | data.resizing = false; 125 | document.removeEventListener('mousemove', resize); 126 | resizeLine?.remove(); 127 | resizeLine = undefined; 128 | 129 | console.log(getWillWidth()); 130 | cb(getWillWidth()); 131 | }, 132 | { 133 | once: true, 134 | }, 135 | ); 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import '@/src/styles/index.scss'; 2 | 3 | import Grid from '@/src/table/VeriTable.vue'; 4 | 5 | export { default as GridTable } from '@/src/template/GridTable.vue'; 6 | export { default as GridTableColumn } from '@/src/template/GridTableColumn.vue'; 7 | 8 | // cells 9 | export { default as SelectView } from '@/src/table/cell/select/SelectView.vue'; 10 | export { default as SelectCover } from '@/src/table/cell/select/SelectCover.vue'; 11 | export { default as SelectDropdown } from '@/src/table/cell/select/SelectDropdown.vue'; 12 | 13 | export { default as DateView } from '@/src/table/cell/date/DateView.vue'; 14 | export { default as DateCover } from '@/src/table/cell/date/DateCover.vue'; 15 | export { default as DateDropdown } from '@/src/table/cell/date/DateDropdown.vue'; 16 | 17 | export { default as LinkView } from '@/src/table/cell/LinkView.vue'; 18 | export { default as PersonView } from '@/src/table/cell/PersonView.vue'; 19 | 20 | export { Grid }; 21 | 22 | export * from '@/src/type'; 23 | -------------------------------------------------------------------------------- /src/interaction/scrollZone.ts: -------------------------------------------------------------------------------- 1 | import type { GridStore } from '@/src/store'; 2 | 3 | export class GridScrollZone { 4 | container?: HTMLElement; 5 | 6 | scrollUpZone!: HTMLElement; 7 | scrollDownZone!: HTMLElement; 8 | 9 | scrollIntervalTimer = 0; 10 | speed = 4; 11 | 12 | lastDirection = 0; 13 | 14 | constructor(private store: GridStore) { 15 | this.scrollUpZone = document.createElement('div'); 16 | this.scrollUpZone.style.width = '3000px'; 17 | this.scrollUpZone.style.height = '64px'; 18 | this.scrollUpZone.style.position = 'absolute'; 19 | this.scrollUpZone.style.left = '0'; 20 | this.scrollUpZone.style.top = '0'; 21 | this.scrollUpZone.style.background = 'transparent'; // 'rgba(255,165,0,0.8)'; 22 | this.scrollUpZone.style.zIndex = '-100'; 23 | this.scrollUpZone.id = 'vtg-scroll-zone__top'; 24 | 25 | this.scrollDownZone = document.createElement('div'); 26 | this.scrollDownZone.style.width = '3000px'; 27 | this.scrollDownZone.style.height = '40px'; 28 | this.scrollDownZone.style.position = 'absolute'; 29 | this.scrollDownZone.style.left = '0'; 30 | this.scrollDownZone.style.bottom = '0'; 31 | this.scrollDownZone.style.background = 'transparent'; // 'rgba(255,165,0,0.8)'; 32 | this.scrollDownZone.style.zIndex = '-100'; 33 | this.scrollUpZone.id = 'vtg-scroll-zone__bottom'; 34 | } 35 | 36 | init(container: HTMLElement) { 37 | this.container = container; 38 | } 39 | 40 | scrollUp(offset: number, base = 1) { 41 | this.scrollIntervalTimer = requestAnimationFrame(() => { 42 | this.store.virtualListRef?.scrollToOffset(offset - this.speed * base); 43 | this.scrollUp(offset, base + 1); 44 | }); 45 | } 46 | 47 | scrollDown(offset: number, base = 1) { 48 | this.scrollIntervalTimer = requestAnimationFrame(() => { 49 | this.store.virtualListRef?.scrollToOffset(offset + this.speed * base); 50 | this.scrollDown(offset, base + 1); 51 | }); 52 | } 53 | 54 | onListScrollUp() { 55 | cancelAnimationFrame(this.scrollIntervalTimer); 56 | const { offset } = this.store.virtualListRef!.reactiveData; 57 | const base = this.lastDirection === -1 ? 2 : 1; 58 | this.scrollUp(offset, base); 59 | } 60 | 61 | onListScrollDown() { 62 | cancelAnimationFrame(this.scrollIntervalTimer); 63 | const { offset } = this.store.virtualListRef!.reactiveData; 64 | const base = this.lastDirection === 1 ? 2 : 1; 65 | this.scrollDown(offset, base); 66 | } 67 | 68 | handler = (e: MouseEvent) => { 69 | const els = document.elementsFromPoint(e.pageX, e.pageY); 70 | if (els.includes(this.scrollUpZone)) { 71 | this.onListScrollUp(); 72 | this.lastDirection = -1; 73 | } else if (els.includes(this.scrollDownZone)) { 74 | this.onListScrollDown(); 75 | this.lastDirection = 1; 76 | } else { 77 | cancelAnimationFrame(this.scrollIntervalTimer); 78 | this.lastDirection = 0; 79 | } 80 | }; 81 | 82 | append() { 83 | this.remove(); 84 | this.container?.appendChild(this.scrollUpZone); 85 | this.container?.appendChild(this.scrollDownZone); 86 | this.container?.addEventListener('mousemove', this.handler); 87 | document.body.addEventListener('mouseup', this.handler); 88 | this.lastDirection = 0; 89 | } 90 | 91 | remove() { 92 | cancelAnimationFrame(this.scrollIntervalTimer); 93 | this.scrollUpZone?.remove(); 94 | this.scrollDownZone?.remove(); 95 | this.container?.removeEventListener('mousemove', this.handler); 96 | document.body.removeEventListener('mouseup', this.handler); 97 | this.lastDirection = 0; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/interaction/selection.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep, isEqual } from 'lodash-es'; 2 | import type { GridStore } from '@/src/store'; 3 | import { getCellFromEvent } from '@/src/utils/getCellFromEvent'; 4 | import { nanoid } from 'nanoid'; 5 | 6 | export class GridSelection { 7 | container?: HTMLElement; 8 | 9 | boxArea = { 10 | left: 0, 11 | top: 0, 12 | right: 0, 13 | bottom: 0, 14 | }; 15 | 16 | id: string = ''; 17 | 18 | startPos = { 19 | rowIndex: -1, 20 | colIndex: -1, 21 | }; 22 | 23 | lastClickPos?: { rowIndex: number; colIndex: number } = undefined; 24 | lastShiftClickPos?: { rowIndex: number; colIndex: number } = undefined; 25 | 26 | callbackFunc: any = () => {}; 27 | 28 | isMetaKey = false; 29 | isShiftKey = false; 30 | 31 | constructor(private store: GridStore) {} 32 | 33 | init(el: HTMLElement) { 34 | this.container = el; 35 | this.container.addEventListener('mousedown', this.onMousedown); 36 | } 37 | 38 | on(fn: any) { 39 | this.callbackFunc = fn; 40 | } 41 | 42 | reset() {} 43 | 44 | preventContextMenu = (e: MouseEvent) => { 45 | e.preventDefault(); 46 | this.onMouseup(); 47 | }; 48 | 49 | onMousedown = (e: MouseEvent) => { 50 | if (!this.store.getState('selection')) return; 51 | // 禁用右键响应 52 | if (e.buttons !== 1 || e.button) return; 53 | const cellInfo = getCellFromEvent(e); 54 | this.isMetaKey = e.metaKey || e.ctrlKey; 55 | this.isShiftKey = e.shiftKey; 56 | 57 | if (cellInfo) { 58 | const { colIndex, rowIndex } = cellInfo; 59 | 60 | this.id = nanoid(4); 61 | 62 | this.startPos = { 63 | rowIndex, 64 | colIndex, 65 | }; 66 | 67 | if (this.isShiftKey) { 68 | if (!this.lastShiftClickPos) { 69 | this.lastShiftClickPos = cloneDeep(this.lastClickPos || this.startPos); 70 | } 71 | } else { 72 | this.lastShiftClickPos = undefined; 73 | } 74 | 75 | if (this.isShiftKey && this.lastShiftClickPos) { 76 | const { rowIndex: ri, colIndex: ci } = this.lastShiftClickPos; 77 | this.boxArea = { 78 | top: Math.min(rowIndex, ri), 79 | bottom: Math.max(rowIndex, ri), 80 | left: Math.min(colIndex, ci), 81 | right: Math.max(colIndex, ci), 82 | }; 83 | } else { 84 | this.boxArea = { 85 | top: rowIndex, 86 | bottom: rowIndex, 87 | left: colIndex, 88 | right: colIndex, 89 | }; 90 | } 91 | 92 | this.lastClickPos = cloneDeep(this.startPos); 93 | 94 | this.container!.style.userSelect = 'none'; 95 | document.body.addEventListener('mouseup', this.onMouseup); 96 | document.body.addEventListener('mouseover', this.onMouseOver); 97 | document.body.addEventListener('contextmenu', this.preventContextMenu); 98 | this.store.interactionModule.gridScrollZone.append(); 99 | this.emitChange(); 100 | } 101 | }; 102 | 103 | onMouseOver = (e: MouseEvent) => { 104 | if (!this.store.getState('selection')) return; 105 | const cellInfo = getCellFromEvent(e); 106 | 107 | if (cellInfo) { 108 | const { colIndex, rowIndex } = cellInfo; 109 | 110 | const newArea = { 111 | top: Math.min(this.startPos.rowIndex, rowIndex), 112 | bottom: Math.max(this.startPos.rowIndex, rowIndex), 113 | left: Math.min(this.startPos.colIndex, colIndex), 114 | right: Math.max(this.startPos.colIndex, colIndex), 115 | }; 116 | 117 | if (!isEqual(newArea, this.boxArea)) { 118 | this.boxArea = newArea; 119 | this.emitChange(); 120 | } 121 | } 122 | }; 123 | 124 | onMouseup = () => { 125 | if (!this.store.getState('selection')) return; 126 | this.id = ''; 127 | this.boxArea = { 128 | left: 0, 129 | top: 0, 130 | right: 0, 131 | bottom: 0, 132 | }; 133 | this.container!.style.userSelect = 'auto'; 134 | document.body.removeEventListener('mouseover', this.onMouseOver); 135 | document.body.removeEventListener('mouseup', this.onMouseup); 136 | document.body.removeEventListener('contextmenu', this.preventContextMenu); 137 | this.store.interactionModule.gridScrollZone.remove(); 138 | }; 139 | 140 | emitChange() { 141 | this.callbackFunc(this.id, this.boxArea, this.isMetaKey); 142 | } 143 | 144 | destroy() { 145 | this.container?.removeEventListener('mousedown', this.onMousedown); 146 | document.body.removeEventListener('contextmenu', this.preventContextMenu); 147 | document.body.removeEventListener('mouseup', this.onMouseup); 148 | document.body.removeEventListener('mouseover', this.onMouseOver); 149 | this.store.interactionModule.gridScrollZone.remove(); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/popper/popper.scss: -------------------------------------------------------------------------------- 1 | .hyd-popper { 2 | position: absolute; 3 | left: 0; 4 | top: 0; 5 | z-index: 100000; 6 | 7 | .popper__arrow { 8 | position: absolute; 9 | display: block; 10 | width: 0; 11 | height: 0; 12 | border-color: transparent; 13 | border-style: solid; 14 | border-width: 6px; 15 | filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 3%)); 16 | 17 | &::after { 18 | position: absolute; 19 | display: block; 20 | width: 0; 21 | height: 0; 22 | border-color: transparent; 23 | border-style: solid; 24 | content: ' '; 25 | border-width: 6px; 26 | } 27 | } 28 | 29 | --hyd-popper-arrow-left: 50%; 30 | --hyd-popper-arrow-top: calc(50% - 6px); 31 | 32 | &[x-placement$='start'] { 33 | --hyd-popper-arrow-left: 10%; 34 | --hyd-popper-arrow-top: 10%; 35 | } 36 | 37 | &[x-placement$='end'] { 38 | --hyd-popper-arrow-left: calc(90% - 12px); 39 | --hyd-popper-arrow-top: calc(90% - 12px); 40 | } 41 | 42 | --hyd-popper-arrow-color: #ccc; 43 | --hyd-popper-arrow-background-color: #fff; 44 | 45 | &[x-placement^='top'] .popper__arrow { 46 | bottom: -6px; 47 | left: var(--hyd-popper-arrow-left); 48 | margin-right: 2px; 49 | border-top-color: var(--hyd-popper-arrow-color); 50 | border-bottom-width: 0; 51 | 52 | &::after { 53 | bottom: 1px; 54 | margin-left: -6px; 55 | border-top-color: var(--hyd-popper-arrow-background-color); 56 | border-bottom-width: 0; 57 | } 58 | } 59 | 60 | &[x-placement^='bottom'] .popper__arrow { 61 | top: -6px; 62 | left: var(--hyd-popper-arrow-left); 63 | margin-right: 2px; 64 | border-top-width: 0; 65 | border-bottom-color: var(--hyd-popper-arrow-color); 66 | 67 | &::after { 68 | top: 1px; 69 | margin-left: -6px; 70 | border-top-width: 0; 71 | border-bottom-color: var(--hyd-popper-arrow-background-color); 72 | } 73 | } 74 | 75 | &[x-placement^='right'] .popper__arrow { 76 | top: var(--hyd-popper-arrow-top); 77 | left: -6px; 78 | margin-bottom: 2px; 79 | border-right-color: var(--hyd-popper-arrow-color); 80 | border-left-width: 0; 81 | 82 | &::after { 83 | bottom: -6px; 84 | left: 1px; 85 | border-right-color: var(--hyd-popper-arrow-background-color); 86 | border-left-width: 0; 87 | } 88 | } 89 | 90 | &[x-placement^='left'] .popper__arrow { 91 | top: var(--hyd-popper-arrow-top); 92 | right: -6px; 93 | margin-bottom: 2px; 94 | border-right-width: 0; 95 | border-left-color: var(--hyd-popper-arrow-color); 96 | 97 | &::after { 98 | right: 1px; 99 | bottom: -6px; 100 | border-right-width: 0; 101 | border-left-color: var(--hyd-popper-arrow-background-color); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/store/column.ts: -------------------------------------------------------------------------------- 1 | import { reactive, shallowReactive } from 'vue'; 2 | import type { GridStore } from '.'; 3 | import type { Column, ColumnItem } from '@/src/type'; 4 | import { formatColumns, type HeaderCellInfo } from '@/src/utils/column'; 5 | 6 | export interface IColumnsRenderInfo { 7 | leftFixedColumns: ColumnItem[]; 8 | rightFixedColumns: ColumnItem[]; 9 | centerNormalHeaderColumns: ColumnItem[][]; 10 | leftFixedHeaderColumns: ColumnItem[][]; 11 | rightFixedHeaderColumns: ColumnItem[][]; 12 | headerCellInfo: HeaderCellInfo; 13 | } 14 | 15 | export class GridColumn { 16 | constructor(private store: GridStore) { 17 | // 18 | } 19 | 20 | // 原始列数据(带 _id),一般不直接用 21 | private originColumns = [] as ColumnItem[]; 22 | // 平铺列(子树列) 23 | flattedColumns = [] as ColumnItem[]; 24 | // 左侧固定列(子树列) 25 | leftFixedColumns = [] as ColumnItem[]; 26 | // 右侧固定列(子树列) 27 | rightFixedColumns = [] as ColumnItem[]; 28 | // 中间主要列(子树列) 29 | centerNormalColumns = [] as ColumnItem[]; 30 | 31 | // 这3个是给表头用的 32 | leftFixedHeaderColumns = [] as ColumnItem[][]; 33 | rightFixedHeaderColumns = [] as ColumnItem[][]; 34 | centerNormalHeaderColumns = [] as ColumnItem[][]; 35 | 36 | columnState = reactive({ 37 | fullWidth: 0, 38 | fixedInfo: { 39 | leftWidth: 0, 40 | rightWidth: 0, 41 | }, 42 | }); 43 | 44 | columnsInfo = shallowReactive({ 45 | leftFixedColumns: [], 46 | rightFixedColumns: [], 47 | leftFixedHeaderColumns: [], 48 | rightFixedHeaderColumns: [], 49 | centerNormalHeaderColumns: [], 50 | headerCellInfo: {}, 51 | }); 52 | 53 | setColumns(columns: Column[]) { 54 | // 存储最原始的列 55 | // 格式化列信息 56 | const { 57 | leftFixedColumns, 58 | rightFixedColumns, 59 | centerNormalColumns, 60 | flattedColumns, 61 | headerCellInfo, 62 | originColumns, 63 | 64 | leftFixedHeaderColumns, 65 | rightFixedHeaderColumns, 66 | centerNormalHeaderColumns, 67 | 68 | fixedInfo, 69 | } = formatColumns(columns); 70 | 71 | this.leftFixedColumns = leftFixedColumns; 72 | this.rightFixedColumns = rightFixedColumns; 73 | this.centerNormalColumns = centerNormalColumns; 74 | this.flattedColumns = flattedColumns; 75 | this.originColumns = originColumns; 76 | 77 | this.columnState.fixedInfo = fixedInfo; 78 | this.columnsInfo.headerCellInfo = headerCellInfo; 79 | this.columnsInfo.leftFixedColumns = leftFixedColumns; 80 | this.columnsInfo.rightFixedColumns = rightFixedColumns; 81 | this.columnsInfo.leftFixedHeaderColumns = leftFixedHeaderColumns; 82 | this.columnsInfo.rightFixedHeaderColumns = rightFixedHeaderColumns; 83 | this.columnsInfo.centerNormalHeaderColumns = centerNormalHeaderColumns; 84 | // this.flattedColumns = flattedColumns; 85 | // // 拿平铺的列进行遍历 86 | // let leftReduce = 0; 87 | // this.flattedColumns.forEach((col) => { 88 | // if (col.fixed === 'left') { 89 | // this.leftFixedColumns.push(Object.assign(col, { left: leftReduce })); 90 | // leftReduce += col.width; 91 | // } else if (col.fixed === 'right') { 92 | // // TODO right的值是要计算出来的 93 | // this.rightFixedColumns.push(col); 94 | // } else { 95 | // this.centerNormalColumns.push(col); 96 | // } 97 | // }); 98 | this.columnState.fullWidth = this.flattedColumns.reduce((a, b) => a + b.width!, 0); 99 | this.store.forceUpdate(); 100 | } 101 | 102 | setColumnWidth(id: string, width: number) { 103 | const column = this.flattedColumns.find((col) => col._id === id); 104 | if (column) { 105 | column.width = width; 106 | this.setColumns(this.originColumns); 107 | } 108 | } 109 | 110 | calcVisibleColumns(scrollLeft: number, clientWidth: number) { 111 | // console.log('calcVisibleColumns', scrollLeft, clientWidth); 112 | let colRenderBegin = 0; 113 | let colRenderEnd = 0; 114 | let currentLeft = 0; 115 | let beginFlag = false; 116 | for (let i = 0; i < this.centerNormalColumns.length; i++) { 117 | const currentWidth = this.centerNormalColumns[i].width!; 118 | // console.log('currentWidth', currentLeft, scrollLeft, scrollLeft + clientWidth); 119 | if (currentLeft >= scrollLeft && !beginFlag) { 120 | colRenderBegin = i; 121 | beginFlag = true; 122 | } else if (currentLeft >= scrollLeft + clientWidth) { 123 | colRenderEnd = i; 124 | // console.log('计算结束', colRenderBegin, colRenderEnd); 125 | break; 126 | } 127 | colRenderEnd = i; 128 | currentLeft += currentWidth; 129 | } 130 | // 给首尾各加一个buffer 131 | // TODO 这里可以减少点 132 | colRenderBegin = Math.max(0, colRenderBegin - 1); 133 | colRenderEnd = Math.min(this.centerNormalColumns.length - 1, colRenderEnd + 1); 134 | 135 | if ( 136 | colRenderBegin !== this.store.mergeModule.mergeState.originRect.xs || 137 | colRenderEnd !== this.store.mergeModule.mergeState.originRect.xe 138 | ) { 139 | // console.warn('横向计算结束', colRenderBegin, colRenderEnd); 140 | 141 | this.store.mergeModule.mergeState.originRect.xs = colRenderBegin; 142 | this.store.mergeModule.mergeState.originRect.xe = colRenderEnd; 143 | 144 | this.store.mergeModule.calcRect(true); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/store/group.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | import type { GridStore } from '.'; 3 | import { CellType, type ListItem } from '@/src/type'; 4 | import { nanoid } from 'nanoid'; 5 | import { useTableEvent } from '@/src/hooks/useEvent/useTableEvent'; 6 | 7 | export class GridGroup { 8 | constructor(private store: GridStore) { 9 | // 10 | } 11 | groupState = reactive({ 12 | // 父子显示的映射 13 | foldMap: {} as Record, 14 | // 展开行显示的映射 15 | expandMap: {} as Record, 16 | }); 17 | 18 | groupFoldConstructor(list: ListItem[], conditions: { columnId: string; sort: 'desc' | 'asc' }[]) { 19 | console.log('groupFoldConstructor', list.length, conditions); 20 | return this.constructGroup(list, 0, conditions); 21 | } 22 | 23 | constructGroup( 24 | list: ListItem[], 25 | conditionIndex: number, 26 | conditions: { columnId: string; sort: 'desc' | 'asc' }[], 27 | ) { 28 | if (conditionIndex >= conditions.length) { 29 | return list; 30 | } 31 | 32 | const { columnId, sort } = conditions[conditionIndex]; 33 | 34 | const sortedList = list.sort((a, b) => { 35 | if (sort === 'desc') { 36 | return (b[columnId] as string).localeCompare(a[columnId] as string); 37 | } 38 | return (a[columnId] as string).localeCompare(b[columnId] as string); 39 | }); 40 | 41 | const res: ListItem[][] = []; 42 | let subGroup: ListItem[] = []; 43 | 44 | for (let i = 0; i < sortedList.length; i++) { 45 | const item = sortedList[i]; 46 | if (item[columnId] === subGroup[subGroup.length - 1]?.[columnId]) { 47 | subGroup.push(item); 48 | } else { 49 | if (subGroup.length) { 50 | res.push(subGroup); 51 | } 52 | subGroup = [item]; 53 | } 54 | } 55 | 56 | if (subGroup.length) { 57 | res.push(subGroup); 58 | } 59 | 60 | const groupList: ListItem[] = []; 61 | res.forEach((item) => { 62 | const v = item[0][columnId]; 63 | groupList.push({ 64 | id: `group-${columnId}-${nanoid(4)}`, 65 | type: 'group', 66 | columnId, 67 | name: v, 68 | children: this.constructGroup(item, conditionIndex + 1, conditions), 69 | }); 70 | }); 71 | 72 | return groupList; 73 | } 74 | 75 | generateFlatList(originList: ListItem[]) { 76 | const flattenList: ListItem[] = []; 77 | 78 | const { foldMap, expandMap } = this.groupState; 79 | 80 | const hasExpandCol = !!this.store.columnModule.flattedColumns.find( 81 | (col) => col.type === CellType.Expand, 82 | ); 83 | 84 | const defaultExpandAll = this.store.getState('defaultExpandAll'); 85 | 86 | this.store.gridRowMap = {}; 87 | 88 | let level = 0; 89 | let groupLevel = 0; 90 | const flat = (list: ListItem[], isGroup = false) => { 91 | list.forEach((item, index) => { 92 | if (isGroup) { 93 | groupLevel += 1; 94 | } 95 | 96 | // const row = { ...item, level, groupLevel, isLastChild: index === list.length - 1 }; 97 | // 需要用原始对象,否则不能响应式 98 | const row = Object.assign(item, { 99 | level, 100 | groupLevel, 101 | isLastChild: index === list.length - 1, 102 | }); 103 | flattenList.push(row); 104 | this.store.gridRowMap[row.id] = row; 105 | 106 | if (item?.children && item?.children?.length > 0) { 107 | level += 1; 108 | foldMap[item.id] = !defaultExpandAll; 109 | if (defaultExpandAll) { 110 | flat(item.children, item.type === 'group'); 111 | } 112 | level -= 1; 113 | } 114 | 115 | if (hasExpandCol) { 116 | expandMap[item.id] = !!defaultExpandAll; 117 | if (defaultExpandAll) { 118 | this.store.gridRowMap[`${item.id}-expand`] = { 119 | id: `${item.id}-expand`, 120 | type: 'expand', 121 | }; 122 | } 123 | } 124 | 125 | if (expandMap[item.id]) { 126 | flattenList.push(this.store.gridRowMap[`${item.id}-expand`]); 127 | } 128 | if (isGroup) { 129 | groupLevel -= 1; 130 | } 131 | }); 132 | }; 133 | flat(originList); 134 | 135 | return flattenList; 136 | } 137 | 138 | resetFlatList(originList: ListItem[]) { 139 | const flattenList: ListItem[] = []; 140 | const { foldMap, expandMap } = this.groupState; 141 | 142 | let level = 0; 143 | let groupLevel = 0; 144 | const flat = (list: ListItem[], isGroup = false) => { 145 | list.forEach((item, index) => { 146 | if (isGroup) { 147 | groupLevel += 1; 148 | } 149 | 150 | // const row = { ...item, level, groupLevel, isLastChild: index === list.length - 1 }; 151 | // 需要用原始对象,否则不能响应式 152 | const row = Object.assign(item, { 153 | level, 154 | groupLevel, 155 | isLastChild: index === list.length - 1, 156 | }); 157 | flattenList.push(row); 158 | this.store.gridRowMap[row.id] = row; 159 | 160 | if (foldMap[item.id] === false && item?.children && item?.children?.length > 0) { 161 | level += 1; 162 | flat(item.children, item.type === 'group'); 163 | level -= 1; 164 | } 165 | if (expandMap[item.id]) { 166 | flattenList.push(this.store.gridRowMap[`${item.id}-expand`]); 167 | } 168 | if (isGroup) { 169 | groupLevel -= 1; 170 | } 171 | }); 172 | }; 173 | flat(originList); 174 | 175 | this.store.setList(flattenList || []); 176 | } 177 | 178 | toggleFold(id: string) { 179 | const { foldMap } = this.groupState; 180 | foldMap[id] = !foldMap[id]; 181 | this.resetFlatList(this.store.originList); 182 | } 183 | 184 | toggleExpand(id: string) { 185 | const tableEvents = useTableEvent(this.store); 186 | const { expandMap } = this.groupState; 187 | expandMap[id] = !expandMap[id]; 188 | if (expandMap[id] && !this.store.gridRowMap[`${id}-expand`]) { 189 | this.store.gridRowMap[`${id}-expand`] = { id: `${id}-expand`, type: 'expand' }; 190 | } 191 | 192 | // TODO yihuang 优化下性能用一次遍历解决 193 | const expandedRowKeys = Object.keys(expandMap) 194 | .filter((key) => !!expandMap[key]) 195 | .map((id) => this.store.gridRowMap[id]); 196 | 197 | tableEvents.onExpandChange({ row: this.store.gridRowMap[id], expandedRows: expandedRowKeys }); 198 | this.resetFlatList(this.store.originList); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/store/popperStore.ts: -------------------------------------------------------------------------------- 1 | import { createApp, nextTick, type App } from 'vue'; 2 | import { createPopper } from '@/src/popper/popper'; 3 | import { GridStore } from '@/src/store'; 4 | import { isFunction } from 'lodash-es'; 5 | 6 | class PopperStore { 7 | gridStore: GridStore; 8 | coverEl: HTMLElement | null = null; 9 | coverApp: App | null = null; 10 | dropdownEl: HTMLElement | null = null; 11 | dropdownApp: App | null = null; 12 | tdData: any; 13 | 14 | constructor(gridStore: GridStore) { 15 | this.gridStore = gridStore; 16 | 17 | this.coverEl = document.createElement('div'); 18 | this.coverEl.classList.add('vtg-popper-container'); 19 | // TODO 需要判断是否有激活单元格选项配置 20 | // this.coverEl.classList.add('vtg-popper-container--cover'); 21 | this.coverEl.addEventListener('click', this.toggleDropdownRender.bind(this)); 22 | 23 | this.dropdownEl = document.createElement('div'); 24 | this.dropdownEl.classList.add('vtg-popper-container'); 25 | } 26 | 27 | coverRender(tdData: any) { 28 | this.tdData = tdData; 29 | 30 | if (!this.coverEl || !this.gridStore.clientEl) return; 31 | if (!isFunction(tdData?.column?.cellCoverRender)) { 32 | // 这里判断一下,没有cover,如果只有dropdown,那就直接渲染dropdown 33 | this.dropdownRender(tdData); 34 | return; 35 | } 36 | this.coverApp = createApp({ 37 | render: () => 38 | tdData.column.cellCoverRender?.(tdData, () => { 39 | this.remove(); 40 | }), 41 | }); 42 | createPopper({ 43 | reference: tdData.el, 44 | mountEl: this.gridStore.clientEl, 45 | popperContainer: this.coverEl, 46 | popper: this.coverApp, 47 | isCover: true, 48 | }); 49 | } 50 | 51 | toggleDropdownRender() { 52 | console.log('111', this.tdData); 53 | if (!this.dropdownEl) return; 54 | console.log('toggleDropdownRender', this.dropdownEl, this.dropdownApp); 55 | 56 | if (this.dropdownApp && this.gridStore.clientEl?.contains(this.dropdownEl)) { 57 | this.gridStore.clientEl?.removeChild(this.dropdownEl); 58 | } else if (this.dropdownApp) { 59 | this.gridStore.clientEl?.appendChild(this.dropdownEl); 60 | } else { 61 | this.dropdownRender(this.tdData); 62 | } 63 | } 64 | 65 | dropdownRender(tdData: any) { 66 | if (!this.coverEl || !this.dropdownEl || !this.gridStore.clientEl) return; 67 | if (!isFunction(tdData?.column?.cellDropdownRender)) return; 68 | 69 | this.dropdownApp = createApp({ 70 | render: () => tdData.column.cellDropdownRender?.(tdData), 71 | }); 72 | 73 | createPopper({ 74 | reference: this.coverEl, 75 | mountEl: this.gridStore.clientEl, 76 | popperContainer: this.dropdownEl, 77 | popper: this.dropdownApp, 78 | isCover: false, 79 | }); 80 | } 81 | 82 | remove() { 83 | console.log('remove'); 84 | if (this.coverEl && this.gridStore.clientEl?.contains(this.coverEl)) { 85 | this.gridStore.clientEl?.removeChild(this.coverEl); 86 | this.coverApp = null; 87 | this.coverEl.removeEventListener('click', this.toggleDropdownRender); 88 | } 89 | if (this.dropdownEl && this.gridStore.clientEl?.contains(this.dropdownEl)) { 90 | this.gridStore.clientEl?.removeChild(this.dropdownEl); 91 | this.dropdownApp = null; 92 | } 93 | } 94 | } 95 | 96 | export { PopperStore }; 97 | -------------------------------------------------------------------------------- /src/styles/theme.scss: -------------------------------------------------------------------------------- 1 | $prefix: 'vtg'; 2 | 3 | :root { 4 | // 表格元素层级 5 | --#{$prefix}-index-normal: 1; 6 | // 表格边框颜色 7 | --#{$prefix}-border-color: #e5e6eb; 8 | // 表格边框 9 | --#{$prefix}-border: 1px solid var(--#{$prefix}-border-color); 10 | // 表格字体颜色 11 | --#{$prefix}-text-color: #606266; 12 | // 表头字体颜色 13 | --#{$prefix}-header-text-color: #909399; 14 | // 表格行hover高亮背景 15 | --#{$prefix}-row-hover-bg-color: #f5f7fa; 16 | // 表格行斑马纹高亮背景 17 | --#{$prefix}-row-stripe-bg-color: #fafafa; 18 | // 行选中背景 19 | --#{$prefix}-current-row-bg-color: #fff7e6; 20 | // 列选中背景 21 | --#{$prefix}-current-column-bg-color: #fff7e6; 22 | // 表头背景 23 | --#{$prefix}-header-bg-color: #f2f3f5; 24 | // 表格固定列阴影 25 | --#{$prefix}-fixed-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12); 26 | // 表格背景颜色 27 | --#{$prefix}-bg-color: #ffffff; 28 | // 表格行背景颜色 29 | --#{$prefix}-tr-bg-color: #ffffff; 30 | // 表格展开列背景颜色 31 | --#{$prefix}-expanded-cell-bg-color: #ffffff; 32 | // 左固定列边框样式 33 | --#{$prefix}-fixed-left-column: inset 10px 0 10px -10px rgba(0, 0, 0, 0.15); 34 | // 右固定列边框样式 35 | --#{$prefix}-fixed-right-column: inset -10px 0 10px -10px rgba(0, 0, 0, 0.15); 36 | // 索引列层级 37 | --#{$prefix}-index: var(--#{$prefix}-index-normal); 38 | // 区域选中边框样式 39 | --#{$prefix}-select-border-color: #409eff; 40 | // 区域选中背景 41 | --#{$prefix}-select-bg-color: #ecf5ff; 42 | // 滚动条滑块颜色 43 | --#{$prefix}-scrollbar-thumb-color: rgba(182, 185, 192, 0.3); 44 | // 滚动条滑块hover颜色 45 | --#{$prefix}-scrollbar-thumb-color-hover: rgba(182, 185, 192, 0.5); 46 | // 树状连线颜色 47 | --#{$prefix}-tree-line-color: #303133; 48 | } 49 | 50 | html.dark { 51 | // 表格边框颜色 52 | --#{$prefix}-border-color: #363637; 53 | // 表格边框 54 | --#{$prefix}-border: 1px solid var(--#{$prefix}-border-color); 55 | // 表格字体颜色 56 | --#{$prefix}-text-color: #cfd3dc; 57 | // 表头字体颜色 58 | --#{$prefix}-header-text-color: #a3a6ad; 59 | // 表格行hover高亮背景 60 | --#{$prefix}-row-hover-bg-color: #262727; 61 | // 表格行斑马纹高亮背景 62 | --#{$prefix}-row-stripe-bg-color: #1d1d1d; 63 | // 行选中背景 64 | --#{$prefix}-current-row-bg-color: #2b2211; 65 | // 行选中背景 66 | --#{$prefix}-current-column-bg-color: #2b2211; 67 | // 表头背景 68 | --#{$prefix}-header-bg-color: #141414; 69 | // 表格固定列阴影 70 | --#{$prefix}-fixed-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.72); 71 | // 表格背景颜色 72 | --#{$prefix}-bg-color: #141414; 73 | // 表格行背景颜色 74 | --#{$prefix}-tr-bg-color: #141414; 75 | // 表格展开列背景颜色 76 | --#{$prefix}-expanded-cell-bg-color: #141414; 77 | // 左固定列边框样式 78 | --#{$prefix}-fixed-left-column: inset 10px 0 10px -10px rgba(0, 0, 0, 0.15); 79 | // 右固定列边框样式 80 | --#{$prefix}-fixed-right-column: inset -10px 0 10px -10px rgba(0, 0, 0, 0.15); 81 | // 索引列层级 82 | --#{$prefix}-index: var(--#{$prefix}-index-normal); 83 | // 区域选中边框样式 84 | --#{$prefix}-select-border-color: #409eff; 85 | // 区域选中背景 86 | --#{$prefix}-select-bg-color: #18222c; 87 | // 滚动条滑块颜色 88 | --#{$prefix}-scrollbar-thumb-color: rgba(163, 166, 173, 0.3); 89 | // 滚动条滑块hover颜色 90 | --#{$prefix}-scrollbar-thumb-color-hover: rgba(163, 166, 173, 0.5); 91 | // 树状连线颜色 92 | --#{$prefix}-tree-line-color: #4c4d4f; 93 | } 94 | -------------------------------------------------------------------------------- /src/table/cell/CheckboxCell.vue: -------------------------------------------------------------------------------- 1 | 13 | 39 | -------------------------------------------------------------------------------- /src/table/cell/ExpandCell.vue: -------------------------------------------------------------------------------- 1 | 19 | 33 | -------------------------------------------------------------------------------- /src/table/cell/IndexCell.vue: -------------------------------------------------------------------------------- 1 | 6 | 16 | -------------------------------------------------------------------------------- /src/table/cell/LinkView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | -------------------------------------------------------------------------------- /src/table/cell/PersonView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 44 | -------------------------------------------------------------------------------- /src/table/cell/RadioCell.vue: -------------------------------------------------------------------------------- 1 | 9 | 26 | -------------------------------------------------------------------------------- /src/table/cell/TextCell.vue: -------------------------------------------------------------------------------- 1 | 6 | 19 | -------------------------------------------------------------------------------- /src/table/cell/TreeCell.vue: -------------------------------------------------------------------------------- 1 | 48 | 69 | 89 | -------------------------------------------------------------------------------- /src/table/cell/date/DateCover.vue: -------------------------------------------------------------------------------- 1 | 22 | 41 | 42 | 63 | -------------------------------------------------------------------------------- /src/table/cell/date/DateDropdown.vue: -------------------------------------------------------------------------------- 1 | 4 | 23 | 24 | 34 | -------------------------------------------------------------------------------- /src/table/cell/date/DateView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 10 | -------------------------------------------------------------------------------- /src/table/cell/link/LinkCover.vue: -------------------------------------------------------------------------------- 1 | 33 | 52 | 53 | 91 | -------------------------------------------------------------------------------- /src/table/cell/link/LinkDropdown.vue: -------------------------------------------------------------------------------- 1 | 16 | 35 | 36 | 76 | -------------------------------------------------------------------------------- /src/table/cell/link/LinkView.vue: -------------------------------------------------------------------------------- 1 | 6 | 27 | 28 | 38 | -------------------------------------------------------------------------------- /src/table/cell/select/SelectCover.vue: -------------------------------------------------------------------------------- 1 | 33 | 69 | 70 | 122 | -------------------------------------------------------------------------------- /src/table/cell/select/SelectDropdown.vue: -------------------------------------------------------------------------------- 1 | 16 | 35 | 36 | 76 | -------------------------------------------------------------------------------- /src/table/cell/select/SelectView.vue: -------------------------------------------------------------------------------- 1 | 16 | 47 | 48 | 100 | -------------------------------------------------------------------------------- /src/table/header/Header.vue: -------------------------------------------------------------------------------- 1 | 11 | 117 | -------------------------------------------------------------------------------- /src/table/header/cell/HeaderCheckboxCell.vue: -------------------------------------------------------------------------------- 1 | 12 | 39 | 40 | -------------------------------------------------------------------------------- /src/table/header/cell/HeaderIndexCell.vue: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /src/table/header/cell/HeaderOrderCell.vue: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /src/table/header/cell/HeaderTextCell.vue: -------------------------------------------------------------------------------- 1 | 9 | 22 | -------------------------------------------------------------------------------- /src/table/row/ExpandRow.vue: -------------------------------------------------------------------------------- 1 | 10 | 37 | -------------------------------------------------------------------------------- /src/table/row/GroupRow.vue: -------------------------------------------------------------------------------- 1 | 33 | 61 | -------------------------------------------------------------------------------- /src/template/GridTable.vue: -------------------------------------------------------------------------------- 1 | 8 | 121 | 122 | -------------------------------------------------------------------------------- /src/template/GridTableColumn.vue: -------------------------------------------------------------------------------- 1 | 4 | 15 | 20 | 21 | -------------------------------------------------------------------------------- /src/utils/getCellFromEvent.ts: -------------------------------------------------------------------------------- 1 | export const getCellFromEvent = (e: MouseEvent) => { 2 | const cellRoot = e 3 | .composedPath() 4 | .find( 5 | (v) => 6 | !(v as HTMLElement).classList?.contains('vtg-cell--unselectable') && 7 | (v as HTMLElement).dataset?.colidx && 8 | (v as HTMLElement).dataset?.rowidx, 9 | ) as HTMLElement; 10 | 11 | if (cellRoot && cellRoot.dataset) { 12 | const { colidx, rowidx, colspan, rowspan } = cellRoot.dataset; 13 | return { 14 | rowIndex: +(rowidx || 0), 15 | colIndex: +(colidx || 0), 16 | rowspan: +(rowspan || 1), 17 | colspan: +(colspan || 1), 18 | }; 19 | } 20 | 21 | return undefined; 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/merge.ts: -------------------------------------------------------------------------------- 1 | import { type MergeCell } from '@/src/type'; 2 | 3 | export function isInMergeCell(mergeInfo: MergeCell, rowIndex: number, colIndex: number) { 4 | return ( 5 | mergeInfo.rowIndex <= rowIndex && 6 | mergeInfo.rowIndex + mergeInfo.rowspan - 1 >= rowIndex && 7 | mergeInfo.colIndex <= colIndex && 8 | mergeInfo.colIndex + mergeInfo.colspan - 1 >= colIndex 9 | ); 10 | } 11 | 12 | export const mergeMethods = ( 13 | rowIndex: number, 14 | colIndex: number, 15 | ): { 16 | rowIndex: number; 17 | colIndex: number; 18 | rowspan: number; 19 | colspan: number; 20 | } | null => { 21 | if (colIndex === 0) { 22 | if (rowIndex % 2 === 0) { 23 | return { 24 | rowIndex: rowIndex, 25 | colIndex: colIndex, 26 | rowspan: 2, 27 | colspan: 1, 28 | }; 29 | } else { 30 | return { 31 | rowIndex: rowIndex - 1, 32 | colIndex: colIndex, 33 | // rowspan: 2, 34 | // colspan: 1, 35 | rowspan: 0, 36 | colspan: 0, 37 | }; 38 | } 39 | } 40 | if (colIndex === 1) { 41 | if (rowIndex % 2 === 1) { 42 | return { 43 | rowIndex: rowIndex, 44 | colIndex: colIndex, 45 | rowspan: 2, 46 | colspan: 1, 47 | }; 48 | } else if (rowIndex > 0) { 49 | return { 50 | rowIndex: rowIndex - 1, 51 | colIndex: colIndex, 52 | rowspan: 2, 53 | colspan: 1, 54 | }; 55 | } 56 | } 57 | return null; 58 | }; 59 | 60 | export function getMergeInfo( 61 | merges: MergeCell[], 62 | rowIndex: number, 63 | colIndex: number, 64 | ): MergeCell | null { 65 | // 测试methods 66 | // return mergeMethods(rowIndex, colIndex); 67 | for (let i = 0; i < merges.length; i += 1) { 68 | if (isInMergeCell(merges[i], rowIndex, colIndex)) { 69 | return merges[i]; 70 | } 71 | } 72 | return null; 73 | } 74 | 75 | /** 76 | * @description 77 | * calcRenderRect计算实际渲染的区域和边界并收集合并单元格信息 78 | * 该函数会在render之前被调用,以便计算实际渲染的区域和边界合并单元格 79 | * 该函数返回的是一个对象,包含了渲染的区域和边界合并单元格 80 | * @param merges {MergeCell[]} - 合并单元格的信息 81 | * @param originRect {Object} - 可视区域的信息 82 | * @return { 83 | * renderRect: { ys: number; ye: number; xs: number; xe: number }, 84 | * merges: { topMerges: MergeCell[], leftMerges: MergeCell[], rightMerges: MergeCell[], bottomMerges: MergeCell[] } 85 | * } - 一个对象,包含了渲染的区域和边界合并单元格 86 | */ 87 | export function calcRenderRect( 88 | merges: MergeCell[], 89 | originRect: { ys: number; ye: number; xs: number; xe: number }, 90 | ) { 91 | const topMerges: MergeCell[] = []; 92 | const leftMerges: MergeCell[] = []; 93 | const rightMerges: MergeCell[] = []; 94 | const bottomMerges: MergeCell[] = []; 95 | const { ys: oys, ye: oye, xs: oxs, xe: oxe } = originRect; 96 | let rys = originRect.ys; 97 | let rye = originRect.ye; 98 | let rxs = originRect.xs; 99 | let rxe = originRect.xe; 100 | 101 | for (let y = oys; y <= oye; y++) { 102 | for (let x = oxs; x <= oxe; x++) { 103 | // 如果是第一行,那么可以算出rys 104 | if (y === oys) { 105 | const mergeInfo = getMergeInfo(merges, y, x); 106 | // console.warn('第一行', y, x, mergeInfo); 107 | if (mergeInfo) { 108 | const { rowIndex, colIndex, colspan } = mergeInfo; 109 | rys = Math.min(rys, rowIndex); 110 | rxs = Math.min(rxs, colIndex); 111 | rxe = Math.max(rxe, colIndex + colspan - 1); 112 | // 只有被上面的行合并的单元格才需要加进来 113 | if (y > rowIndex) { 114 | topMerges.push(mergeInfo); 115 | } 116 | } 117 | } 118 | 119 | // 左边列,不包含第一行和最后一行 120 | if (x === oxs) { 121 | const mergeInfo = getMergeInfo(merges, y, x); 122 | // console.warn('左边列', y, x, mergeInfo); 123 | if (mergeInfo) { 124 | const { colIndex } = mergeInfo; 125 | rxs = Math.min(rxs, colIndex); 126 | // 只有被左边的列合并的单元格才需要加进来 127 | if (x > colIndex) { 128 | leftMerges.push(mergeInfo); 129 | } 130 | } 131 | } 132 | 133 | // 右边列,不包含第一行和最后一行 134 | if (x === oxe) { 135 | const mergeInfo = getMergeInfo(merges, y, x); 136 | // console.warn('右边列', y, x, mergeInfo); 137 | if (mergeInfo) { 138 | const { colIndex, colspan } = mergeInfo; 139 | rxe = Math.max(rxe, colIndex + colspan - 1); 140 | // 只要是有合并信息的都加进去 141 | rightMerges.push(mergeInfo); 142 | } 143 | } 144 | 145 | // 如果是最后一行,那么可以算出rye 146 | if (y === oye) { 147 | const mergeInfo = getMergeInfo(merges, y, x); 148 | if (mergeInfo) { 149 | // 只要是有合并信息的都加进去 150 | const { rowIndex, rowspan } = mergeInfo; 151 | rye = Math.max(rye, rowIndex + rowspan - 1); 152 | bottomMerges.push(mergeInfo); 153 | } 154 | } 155 | } 156 | } 157 | 158 | return { 159 | renderRect: { ys: rys, ye: rye, xs: rxs, xe: rxe }, 160 | merges: { 161 | topMerges, 162 | leftMerges, 163 | rightMerges, 164 | bottomMerges, 165 | }, 166 | }; 167 | } 168 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # todo 2 | 3 | ## done 4 | 5 | - [x] 表头 6 | 7 | ## to-do 8 | 9 | - [ ] 表头合并单元格bug处理 10 | 11 | - [ ] 表格蒙层,全局处理单元格交互,非单元格内部处理,提升性能 12 | 13 | - [ ] 各类单元格 14 | - [ ] select(单元/多选) 15 | - [ ] 日期 16 | - [ ] 人员 17 | - [ ] checkbox 18 | 19 | ## bugfix 20 | 21 | - 高亮 22 | 23 | - 高亮列有bug 24 | - 高亮选中行列(highlight-select-row/col)和高亮hover行(highlight-hover-row/col)要分开 25 | 26 | - 列宽拖拽鼠标样式要改成拖拽 27 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "docs/**/*", "docs/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*", "example/**/__tests__/*"], 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "jsx": "preserve", 8 | "composite": true, 9 | "types": ["node"], 10 | "paths": { 11 | "vue-virt-grid": ["./src"], 12 | "@/src/*": ["./src/*"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "exclude": ["docs/**/*"], 4 | "compilerOptions": { 5 | "emitDeclarationOnly": true, // 只输出声明文件(ts 产物) 6 | "declaration": true, // 自动生成声明文件 7 | "outDir": "lib", 8 | "rootDir": "./src", 9 | "jsx": "preserve", 10 | "jsxFactory": "h" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | }, 10 | { 11 | "path": "./tsconfig.vitest.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Bundler", 14 | "types": ["node"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "exclude": [], 4 | "compilerOptions": { 5 | "composite": true, 6 | "lib": [], 7 | "types": ["node", "jsdom"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'; 3 | import viteConfig from './scripts/dev'; 4 | 5 | export default mergeConfig( 6 | viteConfig, 7 | defineConfig({ 8 | test: { 9 | environment: 'jsdom', 10 | exclude: [...configDefaults.exclude, 'e2e/*'], 11 | root: fileURLToPath(new URL('./', import.meta.url)), 12 | }, 13 | }), 14 | ); 15 | --------------------------------------------------------------------------------