├── .babelrc ├── .github └── workflows │ ├── gh-pages.yml │ ├── main.yml │ └── size.yml ├── .gitignore ├── .storybook ├── main.js └── preview.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── example ├── .gitignore ├── index.html ├── index.tsx ├── package.json └── tsconfig.json ├── jest.config.js ├── package.json ├── src ├── Gantt.less ├── Gantt.tsx ├── components │ ├── bar-list │ │ └── index.tsx │ ├── bar-thumb-list │ │ └── index.tsx │ ├── chart │ │ ├── index.less │ │ └── index.tsx │ ├── dependencies │ │ ├── Dependence.less │ │ ├── Dependence.tsx │ │ └── index.tsx │ ├── divider │ │ ├── index.less │ │ └── index.tsx │ ├── drag-present │ │ └── index.tsx │ ├── drag-resize │ │ ├── AutoScroller.ts │ │ └── index.tsx │ ├── group-bar │ │ ├── index.less │ │ └── index.tsx │ ├── invalid-task-bar │ │ ├── index.less │ │ └── index.tsx │ ├── scroll-bar │ │ ├── index.less │ │ └── index.tsx │ ├── scroll-top │ │ ├── Top.svg │ │ ├── Top_hover.svg │ │ ├── index.less │ │ └── index.tsx │ ├── selection-indicator │ │ ├── index.less │ │ └── index.tsx │ ├── table-body │ │ ├── RowToggler.less │ │ ├── RowToggler.tsx │ │ ├── index.less │ │ └── index.tsx │ ├── table-header │ │ ├── index.less │ │ └── index.tsx │ ├── task-bar-thumb │ │ ├── index.less │ │ └── index.tsx │ ├── task-bar │ │ ├── index.less │ │ └── index.tsx │ ├── time-axis-scale-select │ │ ├── index.less │ │ └── index.tsx │ ├── time-axis │ │ ├── index.less │ │ └── index.tsx │ ├── time-indicator │ │ ├── index.less │ │ └── index.tsx │ └── today │ │ ├── index.less │ │ └── index.tsx ├── constants.ts ├── context.ts ├── hooks │ └── useDragResize.ts ├── index.tsx ├── store.ts ├── style │ ├── index.less │ ├── index.tsx │ └── themes │ │ ├── default.less │ │ └── index.less ├── types.ts └── utils.ts ├── stories ├── 1-basic.stories.tsx ├── 2-latge-data.stories.tsx ├── 3-dependencies.stories.tsx └── utils │ └── createData.ts ├── test └── blah.test.tsx ├── tsconfig.json ├── tsdx.config.js └── typings.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "@babel/plugin-transform-typescript", 5 | { 6 | "allowNamespaces": true 7 | } 8 | ] 9 | ] 10 | } -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: github pages 2 | on: 3 | push: 4 | branches: 5 | - master # default branch 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-18.04 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Install deps and build (with cache) 12 | uses: bahmutov/npm-install@v1 13 | with: 14 | useLockFile: false 15 | install-command: yarn --silent 16 | 17 | - name: Build 18 | run: yarn build-storybook 19 | 20 | - name: Deploy 21 | uses: peaceiris/actions-gh-pages@v3 22 | with: 23 | github_token: ${{ secrets.GITHUB_TOKEN }} 24 | publish_dir: ./storybook-static -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['10.x', '12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | with: 25 | useLockFile: false 26 | install-command: yarn --silent 27 | 28 | - name: Lint 29 | run: yarn lint 30 | 31 | - name: Test 32 | run: yarn test --ci --coverage --maxWorkers=2 33 | 34 | - name: Build 35 | run: yarn build 36 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | yarn.lock -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | stories: ['../stories/**/*.stories.@(ts|tsx|js|jsx)'], 4 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 5 | // https://storybook.js.org/docs/react/configure/typescript#mainjs-configuration 6 | typescript: { 7 | check: true, // type-check stories during Storybook build 8 | }, 9 | webpackFinal: async (config, { configType }) => { 10 | // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' 11 | // You can change the configuration based on that. 12 | // 'PRODUCTION' is used when building the static version of storybook. 13 | 14 | // Make whatever fine-grained changes you need 15 | config.module.rules.push({ 16 | test: /\.less$/, 17 | use: [ 18 | 'style-loader', 19 | { loader: 'css-loader' }, 20 | 'less-loader' 21 | ], 22 | include: path.resolve(__dirname, '../'), 23 | }); 24 | 25 | // Return the altered config 26 | return config; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | // https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters 2 | export const parameters = { 3 | // https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args 4 | actions: { argTypesRegex: '^on.*' }, 5 | }; 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.2 (2021-04-15) 2 | 3 | ### Bug Fixes 4 | 5 | * 修复依赖版本 ([a4eb908](https://github.com/laincarl/react-gantt-component/commit/a4eb908bea4de834d31e2c5acd40ecaa1a433d8b)) 6 | 7 | ### Features 8 | 9 | * **bar:** 拖动后开始时间和结束时间调整 ([e3c76cb](https://github.com/laincarl/react-gantt-component/commit/e3c76cbb8afe3dd9c73e718ea3771aa8ad960579)) 10 | 11 | ### Performance Improvements 12 | 13 | * **time-axis:** 去除hammerjs依赖,内部自主实现移动功能 ([24fa8b9](https://github.com/laincarl/react-gantt-component/commit/24fa8b96e46fac81d6574d64293a9bf0223ce1ed)) 14 | * **time-axis:** 优化时间轴获取,减少逻辑 ([0dd7248](https://github.com/laincarl/react-gantt-component/commit/0dd7248fbaff1627295d7903f52d439ed58ea8ad)) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 laincarl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 基于React的甘特图组件 2 | 3 | ⚠️ ⚠️ ⚠️ 4 | 该组件目前还在开发中,请谨慎使用 5 | ⚠️ ⚠️ ⚠️ 6 | 7 | ## 使用 8 | 9 | ### 安装 10 | 11 | 使用yarn 12 | 13 | ```bash 14 | yarn add react-gantt-component 15 | ``` 16 | 17 | 使用npm 18 | 19 | ```bash 20 | npm install react-gantt-component --save 21 | ``` 22 | 23 | ### 基本使用 24 | 25 | ```js 26 | import React from 'react'; 27 | import ReactDOM from 'react-dom'; 28 | import Gantt from 'react-gantt-component'; 29 | 30 | ReactDOM.render(
31 | { 49 | return true 50 | }} 51 | /> 52 |
,document.getElementById("root")) 53 | ``` 54 | 55 |

配置项

56 | 57 | ### `data` 58 | 59 | 甘特图的数据 60 | 61 | ### `startDateKey` 62 | 63 | - 默认: `startDate` 64 | 65 | 开始时间对应的key 66 | 67 | ### `endDateKey` 68 | 69 | - 默认: `endDate` 70 | 71 | 结束时间对应的key 72 | 73 | ### `columns` 74 | 75 | table的列配置 76 | 77 | ### `onUpdate` 78 | 79 | 时间更新的回调,返回true代表修改成功 80 | 81 | ### `isRestDay` 82 | 83 | - 默认: 周六和周日节假日 84 | 85 | 甘特图的节假日判断,返回true代表节假日 86 | 87 | ### `getBarColor` 88 | 89 | 任务条的颜色配置 90 | 91 | ### `showBackToday` 92 | 93 | - 默认: `true` 94 | 95 | 是否展示回到今天的按钮 96 | 97 | ### `showUnitSwitch` 98 | 99 | - 默认: `true` 100 | 101 | 是否展示单位切换按钮 102 | 103 | ### `unit` 104 | 105 | - 默认: `day` 106 | 107 | 单位,可选的值有`day`,`week`,`month`,`quarter`,`halfYear` 108 | 109 | ### `onRow` 110 | 111 | table的行事件配置,目前支持onClick 112 | 113 | ### `tableIndent` 114 | 115 | - 默认: `30` 116 | 117 | table每一级的缩进 118 | 119 | ### `expandIcon` 120 | 121 | table展开图标 122 | 123 | ### `renderBar` 124 | 125 | 任务条自定义渲染 126 | 127 | ### `renderBarThumb` 128 | 129 | 创建时的任务条自定义渲染 130 | 131 | ### `onBarClick` 132 | 133 | 任务条点击回调 134 | 135 | ### `tableCollapseAble` 136 | 137 | - 默认: `true` 138 | 139 | 是否可以收起table 140 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"] 3 | }; -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import GanttComponent from '../.'; 5 | import '../dist/react-gantt-component.cjs.development.css' 6 | 7 | const App = () => { 8 | return ( 9 |
10 | { 28 | return true 29 | }} 30 | /> 31 |
32 | ); 33 | }; 34 | 35 | ReactDOM.render(, document.getElementById('root')); 36 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { '\\.(css|scss|less)$': 'identity-obj-proxy' }, 3 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.3", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist" 8 | ], 9 | "engines": { 10 | "node": ">=10" 11 | }, 12 | "scripts": { 13 | "start": "tsdx watch", 14 | "build": "tsdx build", 15 | "test": "tsdx test --passWithNoTests", 16 | "lint": "tsdx lint", 17 | "prepare": "tsdx build", 18 | "size": "size-limit", 19 | "analyze": "size-limit --why", 20 | "storybook": "start-storybook -p 6006", 21 | "build-storybook": "build-storybook", 22 | "release": "conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md" 23 | }, 24 | "peerDependencies": { 25 | "react": ">=16" 26 | }, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "tsdx lint", 30 | "commit-msg": "commitlint -e $GIT_PARAMS" 31 | } 32 | }, 33 | "prettier": { 34 | "printWidth": 80, 35 | "semi": true, 36 | "singleQuote": true, 37 | "trailingComma": "es5" 38 | }, 39 | "name": "react-gantt-component", 40 | "author": "laincarl", 41 | "module": "dist/react-gantt-component.esm.js", 42 | "size-limit": [ 43 | { 44 | "path": "dist/react-gantt-component.cjs.production.min.js", 45 | "limit": "10 KB" 46 | }, 47 | { 48 | "path": "dist/react-gantt-component.esm.js", 49 | "limit": "10 KB" 50 | } 51 | ], 52 | "devDependencies": { 53 | "@babel/core": "^7.12.9", 54 | "@commitlint/cli": "^11.0.0", 55 | "@commitlint/config-conventional": "^11.0.0", 56 | "@size-limit/preset-small-lib": "^4.9.1", 57 | "@storybook/addon-controls": "^6.1.11", 58 | "@storybook/addon-essentials": "^6.1.10", 59 | "@storybook/addon-info": "^5.3.21", 60 | "@storybook/addon-links": "^6.1.10", 61 | "@storybook/addons": "^6.1.10", 62 | "@storybook/react": "^6.1.10", 63 | "@types/classnames": "^2.2.11", 64 | "@types/lodash": "^4.14.165", 65 | "@types/react": "^17.0.0", 66 | "@types/react-dom": "^17.0.0", 67 | "babel-loader": "^8.2.2", 68 | "conventional-changelog-cli": "^2.1.1", 69 | "eslint-plugin-prettier": "^3.4.0", 70 | "husky": "^4.3.6", 71 | "identity-obj-proxy": "^3.0.0", 72 | "less": "^3.12.2", 73 | "less-loader": "^7.1.0", 74 | "mobx": "4.7.0", 75 | "mobx-react-lite": "1.5.2", 76 | "postcss": "^8.1.14", 77 | "postcss-url": "^10.1.1", 78 | "react": "^17.0.1", 79 | "react-dom": "^17.0.1", 80 | "react-is": "^17.0.1", 81 | "rollup-plugin-postcss": "^4.0.0", 82 | "size-limit": "^4.9.1", 83 | "tsdx": "^0.14.1", 84 | "tslib": "^2.0.3", 85 | "typescript": "^3.9.7" 86 | }, 87 | "dependencies": { 88 | "ahooks": "^2.9.2", 89 | "classnames": "^2.2.6", 90 | "dayjs": "^1.9.7", 91 | "lodash": "^4.17.20" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Gantt.less: -------------------------------------------------------------------------------- 1 | @import './style/themes/index'; 2 | 3 | .@{gantt-prefix}-body { 4 | 5 | height: 100%; 6 | width: 100%; 7 | display: flex; 8 | flex-direction: column; 9 | position: relative; 10 | border: 1px solid #f0f0f0; 11 | border-radius: 4px; 12 | background: #fff; 13 | 14 | *, 15 | *::before, 16 | *::after { 17 | box-sizing: border-box; 18 | } 19 | 20 | header { 21 | position: relative; 22 | overflow: hidden; 23 | width: 100%; 24 | height: 56px; 25 | } 26 | 27 | main { 28 | position: relative; 29 | overflow-x: hidden; 30 | overflow-y: auto; 31 | width: 100%; 32 | flex: 1; 33 | border-top: 1px solid #f0f0f0; 34 | will-change: transform; 35 | will-change: overflow; 36 | } 37 | } -------------------------------------------------------------------------------- /src/Gantt.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useMemo, 3 | useRef, 4 | useEffect, 5 | useContext, 6 | useImperativeHandle, 7 | } from 'react'; 8 | import { useSize } from 'ahooks'; 9 | import Context, { GanttContext } from './context'; 10 | import GanttStore from './store'; 11 | import Divider from './components/divider'; 12 | import TimeAxis from './components/time-axis'; 13 | import TableHeader from './components/table-header'; 14 | import TableBody from './components/table-body'; 15 | import SelectionIndicator from './components/selection-indicator'; 16 | import TimeAxisScaleSelect from './components/time-axis-scale-select'; 17 | import TimeIndicator from './components/time-indicator'; 18 | import ScrollBar from './components/scroll-bar'; 19 | import Chart from './components/chart'; 20 | import ScrollTop from './components/scroll-top'; 21 | import { DefaultRecordType, Gantt } from './types'; 22 | import { BAR_HEIGHT, ROW_HEIGHT, TABLE_INDENT } from './constants'; 23 | import { Dayjs } from 'dayjs'; 24 | import './Gantt.less'; 25 | 26 | const prefixCls = 'gantt'; 27 | 28 | const Body: React.FC = ({ children }) => { 29 | const { store } = useContext(Context); 30 | const ref = useRef(null); 31 | const size = useSize(ref); 32 | useEffect(() => { 33 | store.syncSize(size); 34 | }, [size, store]); 35 | return ( 36 |
37 | {children} 38 |
39 | ); 40 | }; 41 | export interface GanttProps { 42 | data: Gantt.Record[]; 43 | columns: Gantt.Column[]; 44 | dependencies?: Gantt.Dependence[]; 45 | onUpdate: ( 46 | record: Gantt.Record, 47 | startDate: string, 48 | endDate: string 49 | ) => Promise; 50 | startDateKey?: string; 51 | endDateKey?: string; 52 | isRestDay?: (date: string) => boolean; 53 | unit?: Gantt.Sight; 54 | rowHeight?: number; 55 | innerRef?: React.MutableRefObject; 56 | getBarColor?: GanttContext['getBarColor']; 57 | showBackToday?: GanttContext['showBackToday']; 58 | showUnitSwitch?: GanttContext['showUnitSwitch']; 59 | onRow?: GanttContext['onRow']; 60 | tableIndent?: GanttContext['tableIndent']; 61 | expandIcon?: GanttContext['expandIcon']; 62 | renderBar?: GanttContext['renderBar']; 63 | renderGroupBar?: GanttContext['renderGroupBar']; 64 | renderInvalidBar?: GanttContext['renderInvalidBar']; 65 | renderBarThumb?: GanttContext['renderBarThumb']; 66 | onBarClick?: GanttContext['onBarClick']; 67 | tableCollapseAble?: GanttContext['tableCollapseAble']; 68 | scrollTop?: GanttContext['scrollTop']; 69 | } 70 | export interface GanttRef { 71 | backToday: () => void; 72 | getWidthByDate: (startDate: Dayjs, endDate: Dayjs) => number; 73 | } 74 | const GanttComponent = ( 75 | props: GanttProps 76 | ) => { 77 | const { 78 | data, 79 | columns, 80 | dependencies = [], 81 | onUpdate, 82 | startDateKey = 'startDate', 83 | endDateKey = 'endDate', 84 | isRestDay, 85 | getBarColor, 86 | showBackToday = true, 87 | showUnitSwitch = true, 88 | unit, 89 | onRow, 90 | tableIndent = TABLE_INDENT, 91 | expandIcon, 92 | renderBar, 93 | renderInvalidBar, 94 | renderGroupBar, 95 | onBarClick, 96 | tableCollapseAble = true, 97 | renderBarThumb, 98 | scrollTop = true, 99 | rowHeight = ROW_HEIGHT, 100 | innerRef, 101 | } = props; 102 | const store = useMemo(() => new GanttStore({ rowHeight }), [rowHeight]); 103 | useEffect(() => { 104 | store.setData(data, startDateKey, endDateKey); 105 | }, [data, endDateKey, startDateKey, store]); 106 | useEffect(() => { 107 | store.setColumns(columns); 108 | }, [columns, store]); 109 | useEffect(() => { 110 | store.setOnUpdate(onUpdate); 111 | }, [onUpdate, store]); 112 | useEffect(() => { 113 | store.setDependencies(dependencies); 114 | }, [dependencies, store]); 115 | 116 | useEffect(() => { 117 | if (isRestDay) { 118 | store.setIsRestDay(isRestDay); 119 | } 120 | }, [isRestDay, store]); 121 | useEffect(() => { 122 | if (unit) { 123 | store.switchSight(unit); 124 | } 125 | }, [unit, store]); 126 | useImperativeHandle(innerRef, () => ({ 127 | backToday: () => store.scrollToToday(), 128 | getWidthByDate: store.getWidthByDate, 129 | })); 130 | 131 | const ContextValue = React.useMemo( 132 | () => ({ 133 | prefixCls, 134 | store, 135 | getBarColor, 136 | showBackToday, 137 | showUnitSwitch, 138 | onRow, 139 | tableIndent, 140 | expandIcon, 141 | renderBar, 142 | renderInvalidBar, 143 | renderGroupBar, 144 | onBarClick, 145 | tableCollapseAble, 146 | renderBarThumb, 147 | scrollTop, 148 | barHeight: BAR_HEIGHT, 149 | }), 150 | [ 151 | store, 152 | getBarColor, 153 | showBackToday, 154 | showUnitSwitch, 155 | onRow, 156 | tableIndent, 157 | expandIcon, 158 | renderBar, 159 | renderInvalidBar, 160 | renderGroupBar, 161 | onBarClick, 162 | tableCollapseAble, 163 | renderBarThumb, 164 | scrollTop, 165 | ] 166 | ); 167 | 168 | return ( 169 | 170 | 171 |
172 | 173 | 174 |
175 |
176 | 177 | 178 | 179 |
180 | 181 | {showBackToday && } 182 | {showUnitSwitch && } 183 | 184 | {scrollTop && } 185 | 186 |
187 | ); 188 | }; 189 | export default GanttComponent; 190 | -------------------------------------------------------------------------------- /src/components/bar-list/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import React, { useContext } from 'react'; 3 | import { observer } from 'mobx-react-lite'; 4 | import TaskBar from '../task-bar'; 5 | import InvalidTaskBar from '../invalid-task-bar'; 6 | import GroupBar from '../group-bar'; 7 | import Context from '../../context'; 8 | 9 | const BarList: React.FC = () => { 10 | const { store } = useContext(Context); 11 | const barList = store.getBarList; 12 | const { count, start } = store.getVisibleRows; 13 | return ( 14 | <> 15 | {barList.slice(start, start + count).map((bar) => { 16 | if (bar._group) { 17 | return ; 18 | } 19 | return bar.invalidDateRange ? ( 20 | 21 | ) : ( 22 | 23 | ); 24 | })} 25 | 26 | ); 27 | }; 28 | export default observer(BarList); 29 | -------------------------------------------------------------------------------- /src/components/bar-thumb-list/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import React, { useContext } from 'react'; 3 | import { observer } from 'mobx-react-lite'; 4 | import TaskBarThumb from '../task-bar-thumb'; 5 | import Context from '../../context'; 6 | 7 | const BarThumbList: React.FC = () => { 8 | const { store } = useContext(Context); 9 | const barList = store.getBarList; 10 | const { count, start } = store.getVisibleRows; 11 | return ( 12 | <> 13 | {barList.slice(start, start + count).map((bar) => { 14 | if (store.getTaskBarThumbVisible(bar)) { 15 | return ; 16 | } 17 | return null; 18 | })} 19 | 20 | ); 21 | }; 22 | export default observer(BarThumbList); 23 | -------------------------------------------------------------------------------- /src/components/chart/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | 3 | .@{gantt-prefix}-chart { 4 | position: absolute; 5 | top: 0; 6 | overflow-x: hidden; 7 | overflow-y: hidden; 8 | } 9 | 10 | .@{gantt-prefix}-chart-svg-renderer { 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | } 15 | 16 | .@{gantt-prefix}-render-chunk { 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | will-change: transform; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/chart/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback, useEffect } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import DragPresent from '../drag-present'; 4 | import BarList from '../bar-list'; 5 | import BarThumbList from '../bar-thumb-list'; 6 | import Today from '../today'; 7 | import Dependencies from '../dependencies'; 8 | import Context from '../../context'; 9 | import './index.less'; 10 | 11 | const Chart: React.FC = () => { 12 | const { store, prefixCls } = useContext(Context); 13 | const { 14 | tableWidth, 15 | viewWidth, 16 | bodyScrollHeight, 17 | translateX, 18 | chartElementRef, 19 | } = store; 20 | const minorList = store.getMinorList(); 21 | const handleMouseMove = useCallback( 22 | (event: React.MouseEvent) => { 23 | event.persist(); 24 | store.handleMouseMove(event); 25 | }, 26 | [store] 27 | ); 28 | const handleMouseLeave = useCallback(() => { 29 | store.handleMouseLeave(); 30 | }, [store]); 31 | useEffect(() => { 32 | const element = chartElementRef.current; 33 | if (element) { 34 | element.addEventListener('wheel', store.handleWheel); 35 | } 36 | return () => { 37 | element && element.removeEventListener('wheel', store.handleWheel); 38 | }; 39 | }, [chartElementRef, store]); 40 | return ( 41 |
52 | 60 | 61 | 68 | 69 | 70 | 71 | {minorList.map((item) => 72 | item.isWeek ? ( 73 | 74 | 75 | 84 | 85 | ) : ( 86 | 87 | 88 | 89 | ) 90 | )} 91 | 92 | 93 | 94 |
101 | 102 | 103 | 104 |
105 |
106 | ); 107 | }; 108 | export default observer(Chart); 109 | -------------------------------------------------------------------------------- /src/components/dependencies/Dependence.less: -------------------------------------------------------------------------------- 1 | .task-dependency-line { 2 | z-index: -1; 3 | 4 | .line { 5 | stroke: #f87872; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/dependencies/Dependence.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import find from 'lodash/find'; 4 | import Context from '../../context'; 5 | import styles from './Dependence.less'; 6 | import { Gantt } from '../../types'; 7 | 8 | const spaceX = 10; 9 | const spaceY = 10; 10 | interface DependenceProps { 11 | data: Gantt.Dependence; 12 | } 13 | interface Point { 14 | x: number; 15 | y: number; 16 | } 17 | /** 18 | * 获取关键点 19 | * @param from 20 | * @param to 21 | */ 22 | function getPoints(from: Point, to: Point, type: Gantt.DependenceType) { 23 | const { x: fromX, y: fromY } = from; 24 | const { x: toX, y: toY } = to; 25 | const sameSide = type === 'finish_finish' || type === 'start_start'; 26 | // 同向,只需要两个关键点 27 | if (sameSide) { 28 | if (type === 'start_start') { 29 | return [ 30 | { x: Math.min(fromX - spaceX, toX - spaceX), y: fromY }, 31 | { x: Math.min(fromX - spaceX, toX - spaceX), y: toY }, 32 | ]; 33 | } else { 34 | return [ 35 | { x: Math.max(fromX + spaceX, toX + spaceX), y: fromY }, 36 | { x: Math.max(fromX + spaceX, toX + spaceX), y: toY }, 37 | ]; 38 | } 39 | } 40 | // 不同向,需要四个关键点 41 | 42 | return [ 43 | { x: type === 'finish_start' ? fromX + spaceX : fromX - spaceX, y: fromY }, 44 | { 45 | x: type === 'finish_start' ? fromX + spaceX : fromX - spaceX, 46 | y: toY - spaceY, 47 | }, 48 | { 49 | x: type === 'finish_start' ? toX - spaceX : toX + spaceX, 50 | y: toY - spaceY, 51 | }, 52 | { x: type === 'finish_start' ? toX - spaceX : toX + spaceX, y: toY }, 53 | ]; 54 | } 55 | const Dependence: React.FC = ({ data }) => { 56 | const { store, barHeight } = useContext(Context); 57 | const { from, to, type } = data; 58 | const barList = store.getBarList; 59 | const fromBar = find(barList, (bar) => bar.record.id === from); 60 | const toBar = find(barList, (bar) => bar.record.id === to); 61 | if (!fromBar || !toBar) { 62 | return null; 63 | } 64 | const posY = barHeight / 2; 65 | const [start, end] = (() => { 66 | return [ 67 | { 68 | x: 69 | type === 'finish_finish' || type === 'finish_start' 70 | ? fromBar.translateX + fromBar.width 71 | : fromBar.translateX, 72 | y: fromBar.translateY + posY, 73 | }, 74 | { 75 | x: 76 | type === 'finish_finish' || type === 'start_finish' 77 | ? toBar.translateX + toBar.width 78 | : toBar.translateX, 79 | y: toBar.translateY + posY, 80 | }, 81 | ]; 82 | })(); 83 | const points = [...getPoints(start, end, type), end]; 84 | const endPosition = 85 | type === 'start_finish' || type === 'finish_finish' ? -1 : 1; 86 | return ( 87 | 88 | `L${point.x},${point.y}`).join('\n')} 93 | L${end.x},${end.y} 94 | `} 95 | strokeWidth="1" 96 | fill="none" 97 | /> 98 | 108 | 109 | ); 110 | }; 111 | export default observer(Dependence); 112 | -------------------------------------------------------------------------------- /src/components/dependencies/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import Context from '../../context'; 4 | import Dependence from './Dependence'; 5 | 6 | const Dependencies: React.FC = () => { 7 | const { store } = useContext(Context); 8 | const { dependencies } = store; 9 | return ( 10 | <> 11 | {dependencies.map((dependence) => ( 12 | 13 | ))} 14 | 15 | ); 16 | }; 17 | export default observer(Dependencies); 18 | -------------------------------------------------------------------------------- /src/components/divider/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @divider-prefix-cls: ~'@{gantt-prefix}-divider'; 3 | 4 | .@{divider-prefix-cls} { 5 | position: absolute; 6 | top: 0; 7 | bottom: 0; 8 | cursor: col-resize; 9 | 10 | &:hover { 11 | hr { 12 | border-color: #5365EA; 13 | 14 | &:before { 15 | background: #5365EA; 16 | } 17 | } 18 | 19 | .@{divider-prefix-cls}-icon-wrapper { 20 | background-color: #5365EA; 21 | border-color: #5365EA; 22 | border-top: 0; 23 | border-bottom: 0; 24 | cursor: pointer; 25 | 26 | &:after { 27 | content: ""; 28 | right: -3px; 29 | position: absolute; 30 | width: 2px; 31 | height: 30px; 32 | background-color: transparent 33 | } 34 | 35 | .@{divider-prefix-cls}-arrow:after, 36 | .@{divider-prefix-cls}-arrow:before { 37 | background-color: #fff 38 | } 39 | } 40 | } 41 | 42 | 43 | & > hr { 44 | margin: 0; 45 | height: 100%; 46 | width: 0; 47 | border: none; 48 | border-right: 1px solid transparent; 49 | } 50 | 51 | 52 | & > &-icon-wrapper { 53 | position: absolute; 54 | left: 1px; 55 | top: 50%; 56 | transform: translateY(-50%); 57 | display: flex; 58 | align-items: center; 59 | justify-content: center; 60 | width: 14px; 61 | height: 30px; 62 | border-radius: 0 4px 4px 0; 63 | border: 1px solid #f0f0f0; 64 | border-left: 0; 65 | background-color: #fff; 66 | } 67 | 68 | &-arrow:before { 69 | bottom: -1px; 70 | transform: rotate(30deg) 71 | } 72 | 73 | &-arrow:after { 74 | top: -1px; 75 | transform: rotate(-30deg) 76 | } 77 | 78 | &-arrow:after, 79 | &-arrow:before { 80 | content: ""; 81 | display: block; 82 | position: relative; 83 | width: 2px; 84 | height: 8px; 85 | background-color: #bfbfbf; 86 | border-radius: 1px 87 | } 88 | 89 | &-arrow&-reverse:before { 90 | transform: rotate(-30deg) 91 | } 92 | 93 | &-arrow&-reverse:after { 94 | transform: rotate(30deg) 95 | } 96 | } 97 | 98 | .@{divider-prefix-cls}_only > hr:before { 99 | content: ''; 100 | position: absolute; 101 | border-top: 7px solid white; 102 | border-bottom: 7px solid white; 103 | background: #A7ADD0; 104 | z-index: 2; 105 | height: 26px; 106 | top: 50%; 107 | transform: translateY(-50%); 108 | width: 2px; 109 | } 110 | 111 | .@{divider-prefix-cls}_only > hr { 112 | border-color: #A7ADD0; 113 | } -------------------------------------------------------------------------------- /src/components/divider/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import classNames from 'classnames'; 4 | import useDragResize from '../../hooks/useDragResize'; 5 | import Context from '../../context'; 6 | import './index.less'; 7 | 8 | const Divider: React.FC = () => { 9 | const { store, tableCollapseAble, prefixCls } = useContext(Context); 10 | const prefixClsDivider = `${prefixCls}-divider`; 11 | const { tableWidth } = store; 12 | const handleClick = useCallback( 13 | (event: React.MouseEvent) => { 14 | event.stopPropagation(); 15 | store.toggleCollapse(); 16 | }, 17 | [store] 18 | ); 19 | const left = tableWidth; 20 | 21 | const handleResize = useCallback( 22 | ({ width }: { width: number }) => { 23 | store.handleResizeTableWidth(width); 24 | }, 25 | [store] 26 | ); 27 | const [handleMouseDown, resizing] = useDragResize(handleResize, { 28 | initSize: { 29 | width: tableWidth, 30 | }, 31 | minWidth: 200, 32 | maxWidth: store.width * 0.6, 33 | }); 34 | return ( 35 |
43 | {resizing && ( 44 |
55 | )} 56 |
57 | {tableCollapseAble && ( 58 |
e.stopPropagation()} 62 | onClick={handleClick} 63 | > 64 | 69 |
70 | )} 71 |
72 | ); 73 | }; 74 | export default observer(Divider); 75 | -------------------------------------------------------------------------------- /src/components/drag-present/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import Context from '../../context'; 4 | 5 | /** 6 | * 拖动时的提示条 7 | */ 8 | const DragPresent: React.FC = () => { 9 | const { store } = useContext(Context); 10 | const { dragging, draggingType, bodyScrollHeight } = store; 11 | 12 | if (!dragging) { 13 | return null; 14 | } 15 | // 和当前拖动的块一样长 16 | const { width, translateX } = dragging; 17 | const left = translateX; 18 | const right = translateX + width; 19 | const leftLine = draggingType === 'left' || draggingType === 'move'; 20 | const rightLine = draggingType === 'right' || draggingType === 'move'; 21 | return ( 22 | 23 | {leftLine && } 24 | 31 | {rightLine && } 32 | 33 | ); 34 | }; 35 | export default observer(DragPresent); 36 | -------------------------------------------------------------------------------- /src/components/drag-resize/AutoScroller.ts: -------------------------------------------------------------------------------- 1 | class AutoScroller { 2 | constructor({ 3 | scroller, 4 | rate = 5, 5 | space = 50, 6 | onAutoScroll, 7 | reachEdge, 8 | }: { 9 | scroller?: HTMLElement; 10 | rate?: number; 11 | space?: number; 12 | onAutoScroll: (delta: number) => void; 13 | reachEdge: (position: 'left' | 'right') => boolean; 14 | }) { 15 | this.scroller = scroller || null; 16 | this.rate = rate; 17 | this.space = space; 18 | this.onAutoScroll = onAutoScroll; 19 | this.reachEdge = reachEdge; 20 | } 21 | 22 | rate: number; 23 | 24 | space: number; 25 | 26 | scroller: HTMLElement | null = null; 27 | 28 | autoScrollPos: number = 0; 29 | 30 | clientX: number | null = null; 31 | 32 | scrollTimer: number | null = null; 33 | 34 | onAutoScroll: (delta: number) => void; 35 | 36 | reachEdge: (position: 'left' | 'right') => boolean; 37 | 38 | handleDraggingMouseMove = (event: MouseEvent) => { 39 | this.clientX = event.clientX; 40 | }; 41 | handleScroll = (position: 'left' | 'right') => { 42 | if (this.reachEdge(position)) { 43 | return; 44 | } 45 | if (position === 'left') { 46 | this.autoScrollPos -= this.rate; 47 | this.onAutoScroll(-this.rate); 48 | } else if (position === 'right') { 49 | this.autoScrollPos += this.rate; 50 | this.onAutoScroll(this.rate); 51 | } 52 | }; 53 | 54 | start = () => { 55 | this.autoScrollPos = 0; 56 | document.addEventListener('mousemove', this.handleDraggingMouseMove); 57 | const scrollFunc = () => { 58 | if (this.scroller && this.clientX !== null) { 59 | if ( 60 | this.clientX + this.space > 61 | this.scroller?.getBoundingClientRect().right 62 | ) { 63 | this.handleScroll('right'); 64 | } else if ( 65 | this.clientX - this.space < 66 | this.scroller?.getBoundingClientRect().left 67 | ) { 68 | this.handleScroll('left'); 69 | } 70 | } 71 | 72 | this.scrollTimer = requestAnimationFrame(scrollFunc); 73 | }; 74 | this.scrollTimer = requestAnimationFrame(scrollFunc); 75 | }; 76 | 77 | // 停止自动滚动 78 | stop = () => { 79 | document.removeEventListener('mousemove', this.handleDraggingMouseMove); 80 | if (this.scrollTimer) { 81 | cancelAnimationFrame(this.scrollTimer); 82 | } 83 | }; 84 | } 85 | export default AutoScroller; 86 | -------------------------------------------------------------------------------- /src/components/drag-resize/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useMemo } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | import { usePersistFn } from 'ahooks'; 4 | import { observer } from 'mobx-react-lite'; 5 | import AutoScroller from './AutoScroller'; 6 | 7 | interface Size {} 8 | interface DragResizeProps extends React.HTMLProps { 9 | onResize: ({ width, x }: { width: number; x: number }) => void; 10 | /* 拖拽前的size */ 11 | onResizeEnd?: ({ width, x }: { width: number; x: number }) => void; 12 | onBeforeResize?: () => void; 13 | minWidth?: number; 14 | type: 'left' | 'right' | 'move'; 15 | grid?: number; 16 | scroller?: HTMLElement; 17 | defaultSize: { 18 | width: number; 19 | x: number; 20 | }; 21 | autoScroll?: boolean; 22 | onAutoScroll?: (delta: number) => void; 23 | reachEdge?: (position: 'left' | 'right') => boolean; 24 | /* 点击就算开始 */ 25 | clickStart?: boolean; 26 | } 27 | const snap = (n: number, size: number): number => Math.round(n / size) * size; 28 | const DragResize: React.FC = ({ 29 | type, 30 | onBeforeResize, 31 | onResize, 32 | onResizeEnd, 33 | minWidth = 0, 34 | grid, 35 | defaultSize: { x: defaultX, width: defaultWidth }, 36 | scroller, 37 | autoScroll: enableAutoScroll = true, 38 | onAutoScroll, 39 | reachEdge = () => false, 40 | clickStart = false, 41 | children, 42 | ...otherProps 43 | }) => { 44 | const [resizing, setResizing] = useState(false); 45 | const handleAutoScroll = usePersistFn((delta: number) => { 46 | updateSize(); 47 | onAutoScroll(delta); 48 | }); 49 | // TODO persist reachEdge 50 | const autoScroll = useMemo( 51 | () => 52 | new AutoScroller({ scroller, onAutoScroll: handleAutoScroll, reachEdge }), 53 | [handleAutoScroll, scroller, reachEdge] 54 | ); 55 | const positionRef = useRef({ 56 | clientX: 0, 57 | width: defaultWidth, 58 | x: defaultX, 59 | }); 60 | const moveRef = useRef({ 61 | clientX: 0, 62 | }); 63 | const updateSize = usePersistFn(() => { 64 | const distance = 65 | moveRef.current.clientX - 66 | positionRef.current.clientX + 67 | autoScroll.autoScrollPos; 68 | switch (type) { 69 | case 'left': { 70 | let width = positionRef.current.width - distance; 71 | if (minWidth !== undefined) { 72 | width = Math.max(width, minWidth); 73 | } 74 | if (grid) { 75 | width = snap(width, grid); 76 | } 77 | const pos = width - positionRef.current.width; 78 | const x = positionRef.current.x - pos; 79 | onResize({ width, x }); 80 | break; 81 | } 82 | // 向右,x不变,只变宽度 83 | case 'right': { 84 | let width = positionRef.current.width + distance; 85 | if (minWidth !== undefined) { 86 | width = Math.max(width, minWidth); 87 | } 88 | if (grid) { 89 | width = snap(width, grid); 90 | } 91 | const { x } = positionRef.current; 92 | onResize({ width, x }); 93 | break; 94 | } 95 | case 'move': { 96 | const { width } = positionRef.current; 97 | let rightDistance = distance; 98 | if (grid) { 99 | rightDistance = snap(distance, grid); 100 | } 101 | const x = positionRef.current.x + rightDistance; 102 | onResize({ width, x }); 103 | break; 104 | } 105 | } 106 | }); 107 | const handleMouseMove = usePersistFn((event: MouseEvent) => { 108 | event.preventDefault(); 109 | if (!resizing) { 110 | setResizing(true); 111 | if (!clickStart) { 112 | onBeforeResize && onBeforeResize(); 113 | } 114 | } 115 | moveRef.current.clientX = event.clientX; 116 | updateSize(); 117 | }); 118 | 119 | const handleMouseUp = usePersistFn(() => { 120 | autoScroll.stop(); 121 | window.removeEventListener('mousemove', handleMouseMove); 122 | window.removeEventListener('mouseup', handleMouseUp); 123 | if (resizing) { 124 | setResizing(false); 125 | onResizeEnd && 126 | onResizeEnd({ 127 | x: positionRef.current.x, 128 | width: positionRef.current.width, 129 | }); 130 | } 131 | }); 132 | const handleMouseDown = usePersistFn( 133 | (event: React.MouseEvent) => { 134 | event.stopPropagation(); 135 | event.preventDefault(); 136 | if (enableAutoScroll && scroller) { 137 | autoScroll.start(); 138 | } 139 | if (clickStart) { 140 | onBeforeResize && onBeforeResize(); 141 | setResizing(true); 142 | } 143 | positionRef.current.clientX = event.clientX; 144 | positionRef.current.x = defaultX; 145 | positionRef.current.width = defaultWidth; 146 | window.addEventListener('mousemove', handleMouseMove); 147 | window.addEventListener('mouseup', handleMouseUp); 148 | } 149 | ); 150 | 151 | return ( 152 |
153 | {resizing && 154 | createPortal( 155 |
, 166 | document.body 167 | )} 168 | {children} 169 |
170 | ); 171 | }; 172 | export default observer(DragResize); 173 | -------------------------------------------------------------------------------- /src/components/group-bar/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | 3 | .@{gantt-prefix}-group-bar { 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | display: flex; 8 | 9 | & .@{gantt-prefix}-bar { 10 | position: relative; 11 | top: -3px; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/group-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import classNames from 'classnames'; 4 | import { Gantt } from '../../types'; 5 | import { getMaxRange } from '../../utils'; 6 | import Context from '../../context'; 7 | import './index.less'; 8 | interface GroupBarProps { 9 | data: Gantt.Bar; 10 | } 11 | const height = 8; 12 | const GroupBar: React.FC = ({ data }) => { 13 | const { prefixCls, renderGroupBar, store } = useContext(Context); 14 | const { translateY, task } = data; 15 | const { translateX, width } = task.groupWidthSelf 16 | ? store.getPosition(task.startDate, task.endDate) 17 | : getMaxRange(data); 18 | return ( 19 |
26 |
27 |
28 | {renderGroupBar ? ( 29 | renderGroupBar(data, { 30 | width, 31 | height, 32 | }) 33 | ) : ( 34 | 41 | 57 | 58 | )} 59 |
60 |
61 |
62 | ); 63 | }; 64 | export default observer(GroupBar); 65 | -------------------------------------------------------------------------------- /src/components/invalid-task-bar/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @invalid-task-bar-prefix-cls: ~'@{gantt-prefix}-invalid-task-bar'; 3 | 4 | .@{invalid-task-bar-prefix-cls} { 5 | position: absolute; 6 | left: 0; 7 | width: 100vw; 8 | 9 | &-block { 10 | position: absolute; 11 | width: 16px; 12 | min-width: 8px; 13 | height: 9px; 14 | left: 0; 15 | border: 1px solid; 16 | border-radius: 2px; 17 | cursor: pointer; 18 | z-index: 1 19 | } 20 | 21 | &-date { 22 | position: absolute; 23 | top: -6px; 24 | white-space: nowrap; 25 | color: #262626; 26 | font-size: 12px 27 | } 28 | } -------------------------------------------------------------------------------- /src/components/invalid-task-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback, useState, useRef } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { usePersistFn } from 'ahooks'; 4 | import Context from '../../context'; 5 | import { Gantt } from '../../types'; 6 | import DragResize from '../drag-resize'; 7 | import './index.less'; 8 | interface TaskBarProps { 9 | data: Gantt.Bar; 10 | } 11 | const barH = 8; 12 | let startX = 0; 13 | const renderInvalidBarDefault = (element) => element; 14 | const InvalidTaskBar: React.FC = ({ data }) => { 15 | const { 16 | store, 17 | prefixCls, 18 | renderInvalidBar = renderInvalidBarDefault, 19 | } = useContext(Context); 20 | const triggerRef = useRef(null); 21 | const { translateY, translateX, width, dateTextFormat } = data; 22 | const [visible, setVisible] = useState(false); 23 | const { translateX: viewTranslateX, rowHeight } = store; 24 | const top = translateY; 25 | const prefixClsInvalidTaskBar = `${prefixCls}-invalid-task-bar`; 26 | const handleMouseEnter = useCallback(() => { 27 | if (data.stepGesture === 'moving') { 28 | return; 29 | } 30 | startX = triggerRef.current?.getBoundingClientRect()?.left || 0; 31 | setVisible(true); 32 | }, [data.stepGesture]); 33 | const handleMouseLeave = useCallback(() => { 34 | if (data.stepGesture === 'moving') { 35 | return; 36 | } 37 | setVisible(false); 38 | store.handleInvalidBarLeave(); 39 | }, [data.stepGesture, store]); 40 | const handleMouseMove = useCallback( 41 | (event: React.MouseEvent) => { 42 | if (data.stepGesture === 'moving') { 43 | return; 44 | } 45 | const pointerX = viewTranslateX + (event.clientX - startX); 46 | // eslint-disable-next-line no-shadow 47 | const { left, width } = store.startXRectBar(pointerX); 48 | store.handleInvalidBarHover(data, left, Math.ceil(width)); 49 | }, 50 | [data, store, viewTranslateX] 51 | ); 52 | 53 | const handleBeforeResize = () => { 54 | store.handleInvalidBarDragStart(data); 55 | }; 56 | const handleResize = useCallback( 57 | ({ width: newWidth, x }) => { 58 | store.updateBarSize(data, { width: newWidth, x }); 59 | }, 60 | [data, store] 61 | ); 62 | const handleLeftResizeEnd = useCallback( 63 | (oldSize: { width: number; x: number }) => { 64 | store.handleInvalidBarDragEnd(data, oldSize); 65 | }, 66 | [data, store] 67 | ); 68 | const handleAutoScroll = useCallback( 69 | (delta: number) => { 70 | store.setTranslateX(store.translateX + delta); 71 | }, 72 | [store] 73 | ); 74 | const reachEdge = usePersistFn((position: 'left' | 'right') => { 75 | return position === 'left' && store.translateX <= 0; 76 | }); 77 | 78 | return ( 79 | 98 |
107 | {visible && 108 | renderInvalidBar( 109 | , 138 | data 139 | )} 140 | 141 | ); 142 | }; 143 | export default observer(InvalidTaskBar); 144 | -------------------------------------------------------------------------------- /src/components/scroll-bar/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @scroll-bar-prefix-cls: ~'@{gantt-prefix}-scroll_bar'; 3 | 4 | .@{scroll-bar-prefix-cls} { 5 | position: absolute; 6 | bottom: 0; 7 | left: 16px; 8 | height: 8px; 9 | 10 | &-thumb { 11 | position: absolute; 12 | height: 100%; 13 | border-radius: 4px; 14 | background-color: #262626; 15 | opacity: 0.2; 16 | cursor: pointer; 17 | will-change: transform; 18 | } 19 | } -------------------------------------------------------------------------------- /src/components/scroll-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback, useState, useRef } from 'react'; 2 | import { usePersistFn } from 'ahooks'; 3 | import { observer } from 'mobx-react-lite'; 4 | import Context from '../../context'; 5 | import './index.less'; 6 | 7 | const ScrollBar: React.FC = () => { 8 | const { store, prefixCls } = useContext(Context); 9 | const { tableWidth, viewWidth } = store; 10 | const width = store.scrollBarWidth; 11 | const prefixClsScrollBar = `${prefixCls}-scroll_bar`; 12 | const [resizing, setResizing] = useState(false); 13 | const positionRef = useRef({ 14 | scrollLeft: 0, 15 | left: 0, 16 | translateX: 0, 17 | }); 18 | const handleMouseMove = usePersistFn((event: MouseEvent) => { 19 | const distance = event.clientX - positionRef.current.left; 20 | // TODO 调整倍率 21 | store.setTranslateX( 22 | distance * (store.viewWidth / store.scrollBarWidth) + 23 | positionRef.current.translateX 24 | ); 25 | }); 26 | const handleMouseUp = useCallback(() => { 27 | window.removeEventListener('mousemove', handleMouseMove); 28 | window.removeEventListener('mouseup', handleMouseUp); 29 | setResizing(false); 30 | }, [handleMouseMove]); 31 | const handleMouseDown = useCallback( 32 | (event: React.MouseEvent) => { 33 | positionRef.current.left = event.clientX; 34 | positionRef.current.translateX = store.translateX; 35 | positionRef.current.scrollLeft = store.scrollLeft; 36 | window.addEventListener('mousemove', handleMouseMove); 37 | window.addEventListener('mouseup', handleMouseUp); 38 | setResizing(true); 39 | }, 40 | [handleMouseMove, handleMouseUp, store.scrollLeft, store.translateX] 41 | ); 42 | 43 | return ( 44 |
50 | {resizing && ( 51 |
62 | )} 63 |
70 |
71 | ); 72 | }; 73 | export default observer(ScrollBar); 74 | -------------------------------------------------------------------------------- /src/components/scroll-top/Top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Top 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/components/scroll-top/Top_hover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TOP-hover 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/scroll-top/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @scroll-top-prefix-cls: ~'@{gantt-prefix}-scroll_top'; 3 | 4 | .@{scroll-top-prefix-cls} { 5 | position: absolute; 6 | right: 24px; 7 | bottom: 8px; 8 | width: 40px; 9 | height: 40px; 10 | cursor: pointer; 11 | background-image: url('./Top.svg'); 12 | background-size: contain; 13 | 14 | &:hover { 15 | background-image: url('./Top_hover.svg'); 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/scroll-top/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import Context from '../../context'; 4 | import './index.less'; 5 | 6 | const ScrollTop: React.FC = () => { 7 | const { store, scrollTop: scrollTopConfig, prefixCls } = useContext(Context); 8 | const { scrollTop } = store; 9 | const handleClick = useCallback(() => { 10 | if (store.mainElementRef.current) { 11 | store.mainElementRef.current.scrollTop = 0; 12 | } 13 | }, [store.mainElementRef]); 14 | if (scrollTop <= 100 || !store.mainElementRef.current) { 15 | return null; 16 | } 17 | const prefixClsScrollTop = `${prefixCls}-scroll_top`; 18 | return ( 19 |
24 | ); 25 | }; 26 | export default observer(ScrollTop); 27 | -------------------------------------------------------------------------------- /src/components/selection-indicator/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @selection-indicator-prefix-cls: ~'@{gantt-prefix}-selection-indicator'; 3 | 4 | .@{selection-indicator-prefix-cls} { 5 | position: absolute; 6 | width: 100%; 7 | background: rgba(0, 0, 0, 0.04); 8 | pointer-events: none; 9 | z-index: 10; 10 | } -------------------------------------------------------------------------------- /src/components/selection-indicator/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import Context from '../../context'; 4 | import './index.less'; 5 | 6 | /** 7 | * 鼠标hover效果模拟 8 | */ 9 | const SelectionIndicator: React.FC = () => { 10 | const { store, prefixCls } = useContext(Context); 11 | const { showSelectionIndicator, selectionIndicatorTop, rowHeight } = store; 12 | const prefixClsSelectionIndicator = `${prefixCls}-selection-indicator`; 13 | return showSelectionIndicator ? ( 14 |
21 | ) : null; 22 | }; 23 | export default observer(SelectionIndicator); 24 | -------------------------------------------------------------------------------- /src/components/table-body/RowToggler.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @row-toggler-prefix-cls: ~'@{gantt-prefix}-row-toggler'; 3 | 4 | .@{row-toggler-prefix-cls} { 5 | width: 24px; 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | color: #d9d9d9; 10 | cursor: pointer; 11 | position: relative; 12 | z-index: 5; 13 | 14 | &:hover { 15 | color: #8c8c8c; 16 | } 17 | 18 | & > i { 19 | width: 20px; 20 | height: 20px; 21 | background: white; 22 | 23 | &[data-level='0'] { 24 | border: 1px solid #e5e5e5; 25 | border-radius: 2px; 26 | } 27 | } 28 | 29 | & > i > svg { 30 | transition: transform 218ms; 31 | fill: currentColor; 32 | } 33 | 34 | &-collapsed > i > svg { 35 | transform: rotate(-90deg) 36 | } 37 | } -------------------------------------------------------------------------------- /src/components/table-body/RowToggler.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import './RowToggler.less'; 4 | 5 | interface RowTogglerProps { 6 | onClick: React.DOMAttributes['onClick']; 7 | collapsed: boolean; 8 | level: number; 9 | prefixCls?: string; 10 | } 11 | const RowToggler: React.FC = ({ 12 | onClick, 13 | collapsed, 14 | level, 15 | prefixCls = '', 16 | }) => { 17 | const prefixClsRowToggler = `${prefixCls}-row-toggler`; 18 | return ( 19 |
20 |
25 | 26 | {level <= 0 ? ( 27 | 28 | 29 | 30 | ) : ( 31 | 32 | 33 | 34 | )} 35 | 36 |
37 |
38 | ); 39 | }; 40 | export default RowToggler; 41 | -------------------------------------------------------------------------------- /src/components/table-body/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @table-body-prefix-cls: ~'@{gantt-prefix}-table-body'; 3 | 4 | .@{table-body-prefix-cls} { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | overflow: hidden; 9 | 10 | &-row, 11 | &-border_row { 12 | display: flex; 13 | align-items: center; 14 | position: absolute; 15 | width: 100%; 16 | } 17 | 18 | &-border_row { 19 | height: 100%; 20 | pointer-events: none; 21 | } 22 | 23 | 24 | &-cell { 25 | position: relative; 26 | display: flex; 27 | align-items: center; 28 | border-right: 1px solid #f0f0f0; 29 | height: 100%; 30 | color: rgba(0, 0, 0, 0.65); 31 | user-select: none; 32 | padding: 0 8px; 33 | } 34 | 35 | &-ellipsis { 36 | flex: 1; 37 | overflow: hidden; 38 | text-overflow: ellipsis; 39 | white-space: nowrap; 40 | } 41 | 42 | &-row-indentation { 43 | height: 100%; 44 | position: absolute; 45 | left: 0; 46 | pointer-events: none; 47 | 48 | &:before { 49 | content: ''; 50 | position: absolute; 51 | height: 100%; 52 | left: 0; 53 | width: 1px; 54 | bottom: 0; 55 | background-color: #D9E6F2; 56 | } 57 | 58 | } 59 | 60 | &-row-indentation-both { 61 | &:after { 62 | content: ''; 63 | position: absolute; 64 | width: 100%; 65 | bottom: 0; 66 | left: 0; 67 | height: 1px; 68 | background-color: #D9E6F2; 69 | } 70 | } 71 | 72 | &-row-indentation-hidden { 73 | visibility: hidden; 74 | } 75 | } -------------------------------------------------------------------------------- /src/components/table-body/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import classNames from 'classnames'; 4 | import Context from '../../context'; 5 | import { TOP_PADDING } from '../../constants'; 6 | import RowToggler from './RowToggler'; 7 | import './index.less'; 8 | 9 | const TableRows = () => { 10 | const { store, onRow, tableIndent, expandIcon, prefixCls } = useContext( 11 | Context 12 | ); 13 | const { columns, rowHeight } = store; 14 | const columnsWidth = store.getColumnsWidth; 15 | const barList = store.getBarList; 16 | 17 | const { count, start } = store.getVisibleRows; 18 | const prefixClsTableBody = `${prefixCls}-table-body`; 19 | if (barList.length === 0) { 20 | return ( 21 |
28 | 暂无数据 29 |
30 | ); 31 | } 32 | return ( 33 | <> 34 | {barList.slice(start, start + count).map((bar, rowIndex) => { 35 | // 父元素如果是其最后一个祖先的子,要隐藏上一层的线 36 | const parent = bar._parent; 37 | const parentItem = parent?._parent; 38 | let isLastChild = false; 39 | if (parentItem?.children) { 40 | if ( 41 | parentItem.children[parentItem.children.length - 1] === bar._parent 42 | ) { 43 | isLastChild = true; 44 | } 45 | } 46 | return ( 47 |
{ 56 | onRow?.onClick(bar.record); 57 | }} 58 | > 59 | {columns.map((column, index) => ( 60 |
71 | {index === 0 && 72 | Array(bar._depth) 73 | .fill(0) 74 | .map((_, i) => ( 75 |
93 | ))} 94 | {index === 0 && bar._childrenCount > 0 && ( 95 |
105 | {expandIcon ? ( 106 | expandIcon({ 107 | level: bar._depth, 108 | collapsed: bar._collapsed, 109 | onClick: (event) => { 110 | event.stopPropagation(); 111 | store.setRowCollapse(bar.task, !bar._collapsed); 112 | }, 113 | }) 114 | ) : ( 115 | { 120 | event.stopPropagation(); 121 | store.setRowCollapse(bar.task, !bar._collapsed); 122 | }} 123 | /> 124 | )} 125 |
126 | )} 127 | 128 | {column.render 129 | ? column.render(bar.record) 130 | : // @ts-ignore 131 | bar.record[column.name]} 132 | 133 |
134 | ))} 135 |
136 | ); 137 | })} 138 | 139 | ); 140 | }; 141 | const ObserverTableRows = observer(TableRows); 142 | const TableBorders = () => { 143 | const { store, prefixCls } = useContext(Context); 144 | const { columns } = store; 145 | const columnsWidth = store.getColumnsWidth; 146 | const barList = store.getBarList; 147 | if (barList.length === 0) { 148 | return null; 149 | } 150 | const prefixClsTableBody = `${prefixCls}-table-body`; 151 | return ( 152 |
153 | {columns.map((column, index) => ( 154 |
163 | ))} 164 |
165 | ); 166 | }; 167 | const ObserverTableBorders = observer(TableBorders); 168 | 169 | const TableBody: React.FC = () => { 170 | const { store, prefixCls } = useContext(Context); 171 | const handleMouseMove = useCallback( 172 | (event: React.MouseEvent) => { 173 | event.persist(); 174 | store.handleMouseMove(event); 175 | }, 176 | [store] 177 | ); 178 | const handleMouseLeave = useCallback(() => { 179 | store.handleMouseLeave(); 180 | }, [store]); 181 | const prefixClsTableBody = `${prefixCls}-table-body`; 182 | return ( 183 |
192 | 193 | 194 |
195 | ); 196 | }; 197 | export default observer(TableBody); 198 | -------------------------------------------------------------------------------- /src/components/table-header/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @table-header-prefix-cls: ~'@{gantt-prefix}-table-header'; 3 | 4 | .@{table-header-prefix-cls} { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | overflow: hidden; 9 | 10 | &-head { 11 | position: relative 12 | } 13 | 14 | &-row { 15 | position: absolute; 16 | left: 0; 17 | display: flex; 18 | transition: height 0.3s; 19 | width: 100%; 20 | } 21 | 22 | &-cell { 23 | position: relative; 24 | display: flex; 25 | border-right: 1px solid #f0f0f0 26 | } 27 | 28 | &-head-cell { 29 | display: flex; 30 | flex: 1; 31 | align-items: center; 32 | overflow: hidden; 33 | padding: 0 12px; 34 | // color: #8c8c8c; 35 | user-select: none; 36 | } 37 | 38 | &-ellipsis { 39 | flex: 1; 40 | overflow: hidden; 41 | text-overflow: ellipsis; 42 | white-space: nowrap 43 | } 44 | } -------------------------------------------------------------------------------- /src/components/table-header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import Context from '../../context'; 4 | import './index.less'; 5 | 6 | const TableHeader: React.FC = () => { 7 | const { store, prefixCls } = useContext(Context); 8 | const { columns, tableWidth } = store; 9 | const width = tableWidth; 10 | const columnsWidth = store.getColumnsWidth; 11 | const prefixClsTableHeader = `${prefixCls}-table-header`; 12 | return ( 13 |
14 |
18 |
19 | {columns.map((column, index) => ( 20 |
29 |
30 | 31 | {column.label} 32 | 33 |
34 |
35 | ))} 36 |
37 |
38 |
39 | ); 40 | }; 41 | export default observer(TableHeader); 42 | -------------------------------------------------------------------------------- /src/components/task-bar-thumb/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @task-bar-thumb-prefix-cls: ~'@{gantt-prefix}-task-bar-thumb'; 3 | 4 | .@{task-bar-thumb-prefix-cls} { 5 | position: absolute; 6 | cursor: pointer; 7 | white-space: nowrap; 8 | z-index: 2; 9 | overflow: hidden; 10 | max-width: 200px; 11 | color: #595959; 12 | text-overflow: ellipsis; 13 | word-break: keep-all; 14 | line-height: 16px; 15 | user-select: none; 16 | font-size: 12px; 17 | padding-right: 16px; 18 | 19 | &-left { 20 | transform: translate(0); 21 | } 22 | 23 | &-right { 24 | transform: translate(-100%); 25 | } 26 | } -------------------------------------------------------------------------------- /src/components/task-bar-thumb/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo, useCallback } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import classNames from 'classnames'; 4 | import Context from '../../context'; 5 | import { Gantt } from '../../types'; 6 | import './index.less'; 7 | interface TaskBarProps { 8 | data: Gantt.Bar; 9 | } 10 | 11 | const TaskBarThumb: React.FC = ({ data }) => { 12 | const { store, renderBarThumb, prefixCls } = useContext(Context); 13 | const prefixClsTaskBarThumb = `${prefixCls}-task-bar-thumb`; 14 | const { translateX: viewTranslateX, viewWidth } = store; 15 | const { translateX, translateY, label } = data; 16 | const type = useMemo(() => { 17 | const rightSide = viewTranslateX + viewWidth; 18 | const right = translateX; 19 | 20 | return right - rightSide > 0 ? 'right' : 'left'; 21 | }, [translateX, viewTranslateX, viewWidth]); 22 | const left = useMemo( 23 | () => 24 | type === 'right' ? viewTranslateX + viewWidth - 5 : viewTranslateX + 2, 25 | [type, viewTranslateX, viewWidth] 26 | ); 27 | const handleClick = useCallback( 28 | (e: React.MouseEvent) => { 29 | e.stopPropagation(); 30 | store.scrollToBar(data, type); 31 | }, 32 | [data, store, type] 33 | ); 34 | return ( 35 |
47 | {renderBarThumb ? renderBarThumb(data.record, type) : label} 48 |
49 | ); 50 | }; 51 | export default observer(TaskBarThumb); 52 | -------------------------------------------------------------------------------- /src/components/task-bar/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @task-bar-prefix-cls: ~'@{gantt-prefix}-task-bar'; 3 | 4 | .@{task-bar-prefix-cls} { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | display: flex; 9 | 10 | &-loading { 11 | position: absolute; 12 | top: 0; 13 | bottom: 0; 14 | left: 0; 15 | right: 0; 16 | cursor: not-allowed; 17 | z-index: 9; 18 | } 19 | 20 | &-bar { 21 | position: relative; 22 | height: 8px; 23 | line-height: 8px; 24 | border-radius: 4px; 25 | top: -1px; 26 | cursor: pointer; 27 | } 28 | 29 | &-invalid-date-range { 30 | display: none; 31 | } 32 | 33 | 34 | &-resize-bg { 35 | position: absolute; 36 | left: 0; 37 | top: -5px; 38 | border-radius: 4px; 39 | box-shadow: 0 2px 4px 0 #f7f7f7; 40 | border: 1px solid #f0f0f0; 41 | background-color: #fff; 42 | 43 | &-compact { 44 | height: 17px 45 | } 46 | } 47 | 48 | &-resize-handle { 49 | position: absolute; 50 | left: 0; 51 | top: -4px; 52 | width: 14px; 53 | height: 16px; 54 | z-index: 3; 55 | background: white; 56 | 57 | &:after, 58 | &:before { 59 | position: absolute; 60 | top: 4px; 61 | bottom: 16px; 62 | width: 2px; 63 | height: 8px; 64 | border-radius: 2px; 65 | background-color: #d9d9d9; 66 | content: "" 67 | } 68 | 69 | &-left { 70 | cursor: col-resize; 71 | 72 | &:before { 73 | left: 4px 74 | } 75 | 76 | &:after { 77 | right: 4px 78 | } 79 | } 80 | 81 | 82 | &-right { 83 | cursor: col-resize; 84 | 85 | &:before { 86 | left: 4px 87 | } 88 | 89 | &:after { 90 | right: 4px 91 | } 92 | } 93 | } 94 | 95 | 96 | 97 | &-date-text { 98 | color: #262626 99 | } 100 | 101 | &-date-text, 102 | &-label { 103 | position: absolute; 104 | white-space: nowrap; 105 | font-size: 12px; 106 | top: -6px 107 | } 108 | 109 | &-label { 110 | overflow: hidden; 111 | max-width: 200px; 112 | color: #595959; 113 | text-overflow: ellipsis; 114 | word-break: keep-all; 115 | line-height: 16px; 116 | -webkit-user-select: none; 117 | -moz-user-select: none; 118 | -ms-user-select: none; 119 | user-select: none; 120 | height: 16px; 121 | cursor: pointer 122 | } 123 | 124 | // &-dependency-handle { 125 | // position: absolute; 126 | // top: -5px; 127 | // left: 0; 128 | // cursor: pointer; 129 | // height: 18px; 130 | // z-index: 1; 131 | 132 | // &:hover { 133 | // .inner { 134 | // fill: #1b9aee 135 | // } 136 | // } 137 | 138 | // &.right { 139 | // text-align: right 140 | // } 141 | 142 | // &.loose { 143 | // top: 0 144 | // } 145 | // } 146 | 147 | 148 | // .done .dependency-handle .outer { 149 | // stroke: #d9d9d9 150 | // } 151 | 152 | // .done .dependency-handle .inner { 153 | // fill: #d9d9d9 154 | // } 155 | 156 | // .done .dependency-handle:hover .inner { 157 | // fill: #8c8c8c 158 | // } 159 | } 160 | 161 | // .overdue { 162 | // .dependency-handle .outer { 163 | // stroke: #fcc 164 | // } 165 | 166 | // .dependency-handle .inner { 167 | // fill: #fd998f 168 | // } 169 | 170 | // .dependency-handle:hover .inner { 171 | // fill: #f87872 172 | // } 173 | // } -------------------------------------------------------------------------------- /src/components/task-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo, useCallback } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import classNames from 'classnames'; 4 | import dayjs from 'dayjs'; 5 | import { usePersistFn } from 'ahooks'; 6 | import Context from '../../context'; 7 | import { Gantt } from '../../types'; 8 | import DragResize from '../drag-resize'; 9 | import './index.less'; 10 | import { TOP_PADDING } from '../../constants'; 11 | import { ONE_DAY_MS } from '../../store'; 12 | interface TaskBarProps { 13 | data: Gantt.Bar; 14 | } 15 | 16 | const TaskBar: React.FC = ({ data }) => { 17 | const { 18 | store, 19 | getBarColor, 20 | renderBar, 21 | onBarClick, 22 | prefixCls, 23 | barHeight, 24 | } = useContext(Context); 25 | const { 26 | width, 27 | translateX, 28 | translateY, 29 | invalidDateRange, 30 | stepGesture, 31 | dateTextFormat, 32 | record, 33 | loading, 34 | } = data; 35 | const prefixClsTaskBar = `${prefixCls}-task-bar`; 36 | // TODO 优化hover判断性能 37 | const { selectionIndicatorTop, showSelectionIndicator, rowHeight } = store; 38 | 39 | const showDragBar = useMemo(() => { 40 | if (!showSelectionIndicator) { 41 | return false; 42 | } 43 | // 差值 44 | const baseTop = TOP_PADDING + rowHeight / 2 - barHeight / 2; 45 | const isShow = selectionIndicatorTop === translateY - baseTop; 46 | return isShow; 47 | }, [ 48 | showSelectionIndicator, 49 | selectionIndicatorTop, 50 | translateY, 51 | rowHeight, 52 | barHeight, 53 | ]); 54 | const themeColor = useMemo(() => { 55 | if (translateX + width >= dayjs().valueOf() / store.pxUnitAmp) { 56 | return ['#95DDFF', '#64C7FE']; 57 | } 58 | return ['#FD998F', '#F96B5D']; 59 | }, [store.pxUnitAmp, translateX, width]); 60 | const handleBeforeResize = (type: Gantt.MoveType) => () => { 61 | store.handleDragStart(data, type); 62 | }; 63 | const handleResize = useCallback( 64 | ({ width: newWidth, x }) => { 65 | store.updateBarSize(data, { width: newWidth, x }); 66 | }, 67 | [data, store] 68 | ); 69 | const handleLeftResizeEnd = useCallback( 70 | (oldSize: { width: number; x: number }) => { 71 | store.handleDragEnd(); 72 | store.updateTaskDate(data, oldSize, 'left'); 73 | }, 74 | [data, store] 75 | ); 76 | const handleRightResizeEnd = useCallback( 77 | (oldSize: { width: number; x: number }) => { 78 | store.handleDragEnd(); 79 | store.updateTaskDate(data, oldSize, 'right'); 80 | }, 81 | [data, store] 82 | ); 83 | 84 | const handleMoveEnd = useCallback( 85 | (oldSize: { width: number; x: number }) => { 86 | store.handleDragEnd(); 87 | store.updateTaskDate(data, oldSize, 'move'); 88 | }, 89 | [data, store] 90 | ); 91 | const handleAutoScroll = useCallback( 92 | (delta: number) => { 93 | store.setTranslateX(store.translateX + delta); 94 | }, 95 | [store] 96 | ); 97 | const allowDrag = showDragBar && !loading; 98 | const handleClick = useCallback( 99 | (e: React.MouseEvent) => { 100 | e.stopPropagation(); 101 | onBarClick && onBarClick(data.record); 102 | }, 103 | [data.record, onBarClick] 104 | ); 105 | const reachEdge = usePersistFn((position: 'left' | 'right') => { 106 | return position === 'left' && store.translateX <= 0; 107 | }); 108 | // 根据不同的视图确定拖动时的单位,在任何视图下都以一天为单位 109 | const grid = useMemo(() => ONE_DAY_MS / store.pxUnitAmp, [store.pxUnitAmp]); 110 | return ( 111 |
122 | {loading &&
} 123 |
124 | {allowDrag && ( 125 | <> 126 | {/* {stepGesture !== 'moving' && ( 127 |
128 | 129 | 130 | 131 | 132 | 133 | 134 |
135 | )} 136 | {stepGesture !== 'moving' && ( 137 |
138 | 139 | 140 | 141 | 142 | 143 | 144 |
145 | )} */} 146 | 166 | 186 |
193 | 194 | )} 195 | 211 | {renderBar ? ( 212 | renderBar(data, { 213 | width: width + 1, 214 | height: barHeight + 1, 215 | }) 216 | ) : ( 217 | 224 | 257 | 258 | )} 259 | 260 |
261 | {/* {stepGesture !== 'moving' && ( 262 |
263 | {label} 264 |
265 | )} */} 266 | {stepGesture === 'moving' && ( 267 | <> 268 |
272 | {dateTextFormat(translateX + width)} 273 |
274 |
278 | {dateTextFormat(translateX)} 279 |
280 | 281 | )} 282 |
283 | ); 284 | }; 285 | export default observer(TaskBar); 286 | -------------------------------------------------------------------------------- /src/components/time-axis-scale-select/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | 3 | .@{gantt-prefix}-time-axis-scale-select { 4 | .next-menu { 5 | position: relative; 6 | min-width: 150px; 7 | padding: 4px 0; 8 | margin: 0; 9 | list-style: none; 10 | border-radius: 4px; 11 | background: #fff; 12 | line-height: 36px; 13 | font-size: 14px 14 | } 15 | 16 | .next-menu, 17 | .next-menu *, 18 | .next-menu :after, 19 | .next-menu :before { 20 | box-sizing: border-box 21 | } 22 | 23 | .next-menu, 24 | .next-select-trigger, 25 | .next-select .next-select-inner { 26 | min-width: unset 27 | } 28 | 29 | .next-menu-item-text { 30 | line-height: 36px 31 | } 32 | } 33 | 34 | .time-axis-scale-select__3fTI .next-menu-item-text { 35 | line-height: 36px 36 | } 37 | 38 | .@{gantt-prefix}-shadow { 39 | position: absolute; 40 | top: 4px; 41 | right: 0; 42 | width: 90px; 43 | height: 48px; 44 | z-index: 0; 45 | transition: box-shadow 0.5s 46 | } 47 | 48 | .@{gantt-prefix}-shadow.@{gantt-prefix}-scrolling { 49 | box-shadow: -3px 0 7px 0 #e5e5e5 50 | } 51 | 52 | .@{gantt-prefix}-trigger { 53 | position: absolute; 54 | top: 0; 55 | right: 0; 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | height: 56px; 60 | border-top-right-radius: 4px; 61 | background-color: #fff; 62 | border-left: 1px solid #f0f0f0; 63 | color: #bfbfbf; 64 | padding: 0 8px 0 12px; 65 | cursor: pointer; 66 | width: 90px; 67 | z-index: 1; 68 | transition: color 0.2s 69 | } 70 | 71 | .@{gantt-prefix}-trigger:hover { 72 | color: #8c8c8c 73 | } 74 | 75 | .@{gantt-prefix}-trigger:hover .@{gantt-prefix}-text { 76 | color: #262626 77 | } 78 | 79 | .@{gantt-prefix}-trigger .@{gantt-prefix}-text { 80 | white-space: nowrap; 81 | overflow: hidden; 82 | text-overflow: ellipsis; 83 | margin-right: 4px; 84 | font-size: 14px; 85 | color: #8c8c8c 86 | } 87 | 88 | .dropdown-icon { 89 | width: 20px; 90 | height: 20px; 91 | line-height: 20px; 92 | 93 | svg { 94 | fill: currentColor; 95 | } 96 | } 97 | 98 | .next-overlay-wrapper { 99 | position: absolute; 100 | top: 0; 101 | left: 0; 102 | width: 100% 103 | } 104 | 105 | .next-overlay-wrapper .next-overlay-inner { 106 | z-index: 1001; 107 | border-radius: 4px; 108 | box-shadow: 0 12px 32px 0 rgba(38, 38, 38, 0.16); 109 | -webkit-transform: translateZ(0); 110 | transform: translateZ(0) 111 | } 112 | 113 | .next-overlay-wrapper .next-overlay-backdrop { 114 | position: fixed; 115 | z-index: 1001; 116 | top: 0; 117 | left: 0; 118 | width: 100%; 119 | height: 100%; 120 | background: #000; 121 | transition: opacity .3s; 122 | opacity: 0 123 | } 124 | 125 | .next-overlay-wrapper.opened .next-overlay-backdrop { 126 | opacity: .3 127 | } 128 | 129 | .next-menu-item { 130 | position: relative; 131 | padding: 0 12px 0 40px; 132 | transition: background .2s ease; 133 | color: #262626; 134 | cursor: pointer; 135 | display: flex; 136 | align-items: center; 137 | 138 | .@{gantt-prefix}-selected_icon { 139 | position: absolute; 140 | left: 12px; 141 | width: 20px; 142 | height: 20px; 143 | line-height: 20px; 144 | 145 | svg { 146 | fill: rgb(27, 154, 238); 147 | } 148 | } 149 | 150 | &:hover { 151 | font-weight: 400; 152 | background-color: #f7f7f7; 153 | } 154 | } 155 | 156 | .next-menu-item.next-selected { 157 | color: #262626; 158 | background-color: #fff 159 | } 160 | 161 | .next-menu-item.next-selected .next-menu-icon-arrow { 162 | color: #bfbfbf 163 | } 164 | -------------------------------------------------------------------------------- /src/components/time-axis-scale-select/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback, useState, useRef } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import classNames from 'classnames'; 4 | import { useClickAway } from 'ahooks'; 5 | import Context from '../../context'; 6 | import { viewTypeList } from '../../store'; 7 | import { Gantt } from '../../types'; 8 | import './index.less'; 9 | 10 | const TimeAxisScaleSelect: React.FC = () => { 11 | const { store, prefixCls } = useContext(Context); 12 | const { sightConfig, scrolling } = store; 13 | const [visible, setVisible] = useState(false); 14 | const ref = useRef(null); 15 | useClickAway(() => { 16 | setVisible(false); 17 | }, ref); 18 | const handleClick = useCallback(() => { 19 | setVisible(true); 20 | }, []); 21 | const handleSelect = useCallback( 22 | (item: Gantt.SightConfig) => { 23 | store.switchSight(item.type); 24 | setVisible(false); 25 | }, 26 | [store] 27 | ); 28 | const selected = sightConfig.type; 29 | const isSelected = useCallback((key: string) => key === selected, [selected]); 30 | return ( 31 |
32 |
33 |
34 | {sightConfig.label} 35 | 视图 36 |
37 | 38 | 39 | 40 | 41 | 42 |
43 |
48 | {visible && ( 49 |
50 |
55 |
56 |
    61 | {viewTypeList.map((item) => ( 62 |
  • { 66 | handleSelect(item); 67 | }} 68 | className={classNames('next-menu-item', { 69 | 'next-selected': isSelected(item.type), 70 | })} 71 | > 72 | {isSelected(item.type) && ( 73 | 74 | 75 | 76 | 77 | 78 | )} 79 | 83 | {item.label} 84 | 85 |
  • 86 | ))} 87 |
88 |
89 |
90 |
91 | )} 92 |
93 | ); 94 | }; 95 | export default observer(TimeAxisScaleSelect); 96 | -------------------------------------------------------------------------------- /src/components/time-axis/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @time-axis-prefix-cls: ~'@{gantt-prefix}-time-axis'; 3 | 4 | .@{time-axis-prefix-cls} { 5 | height: 56px; 6 | position: absolute; 7 | top: 0; 8 | user-select: none; 9 | overflow: hidden; 10 | cursor: ew-resize; 11 | 12 | &-render-chunk { 13 | position: absolute; 14 | top: 0; 15 | left: 0; 16 | height: 56px; 17 | pointer-events: none; 18 | user-select: none; 19 | will-change: transform; 20 | } 21 | 22 | &-major { 23 | position: absolute; 24 | overflow: hidden; 25 | box-sizing: content-box; 26 | height: 28px; 27 | border-right: 1px solid #f0f0f0; 28 | font-weight: 500; 29 | text-align: left; 30 | font-size: 13px; 31 | line-height: 28px; 32 | 33 | &-label { 34 | overflow: hidden; 35 | padding-left: 8px; 36 | white-space: nowrap; 37 | } 38 | } 39 | 40 | &-minor { 41 | position: absolute; 42 | top: 27px; 43 | box-sizing: content-box; 44 | height: 28px; 45 | border-top: 1px solid #f0f0f0; 46 | border-right: 1px solid #f0f0f0; 47 | color: rgba(0, 0, 0, 0.65); 48 | text-align: center; 49 | font-weight: 300; 50 | font-size: 12px; 51 | line-height: 28px; 52 | 53 | &.weekends { 54 | background-color: hsla(0, 0%, 96.9%, 0.5); 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /src/components/time-axis/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import classNames from 'classnames'; 4 | import DragResize from '../drag-resize'; 5 | import Context from '../../context'; 6 | 7 | import './index.less'; 8 | 9 | const TimeAxis: React.FC = () => { 10 | const { store, prefixCls } = useContext(Context); 11 | const prefixClsTimeAxis = `${prefixCls}-time-axis`; 12 | const majorList = store.getMajorList(); 13 | const minorList = store.getMinorList(); 14 | const handleResize = useCallback( 15 | ({ x }) => { 16 | store.handlePanMove(-x); 17 | }, 18 | [store] 19 | ); 20 | const handleLeftResizeEnd = useCallback(() => { 21 | store.handlePanEnd(); 22 | }, [store]); 23 | return ( 24 | 33 |
40 |
46 | {majorList.map((item) => ( 47 |
52 |
53 | {item.label} 54 |
55 |
56 | ))} 57 | {minorList.map((item) => ( 58 |
65 |
66 | {item.label} 67 |
68 |
69 | ))} 70 |
71 |
72 |
73 | ); 74 | }; 75 | export default observer(TimeAxis); 76 | -------------------------------------------------------------------------------- /src/components/time-indicator/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | @time-indicator-prefix-cls: ~'@{gantt-prefix}-time-indicator'; 3 | 4 | .@{time-indicator-prefix-cls} { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | background-color: #f2fbff; 9 | box-shadow: 0 2px 4px rgba(1, 113, 194, 0.1); 10 | transform: translate(12px, 14px); 11 | transition: opacity 0.3s; 12 | // button style 13 | padding: 0 7px; 14 | border: 1px solid #ccecff; 15 | color: #1b9aee; 16 | border-radius: 4px; 17 | outline: 0; 18 | display: inline-flex; 19 | align-items: center; 20 | justify-content: center; 21 | box-sizing: border-box; 22 | user-select: none; 23 | vertical-align: middle; 24 | cursor: pointer; 25 | &-scrolling { 26 | opacity: 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/time-indicator/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback, useMemo } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import classNames from 'classnames'; 4 | import Context from '../../context'; 5 | import './index.less'; 6 | 7 | const TimeIndicator: React.FC = () => { 8 | const { store, prefixCls } = useContext(Context); 9 | const { 10 | scrolling, 11 | translateX, 12 | tableWidth, 13 | viewWidth, 14 | todayTranslateX, 15 | } = store; 16 | const prefixClsTimeIndicator = `${prefixCls}-time-indicator`; 17 | const type = todayTranslateX < translateX ? 'left' : 'right'; 18 | const left = type === 'left' ? tableWidth : 'unset'; 19 | const right = type === 'right' ? 111 : 'unset'; 20 | const display = useMemo(() => { 21 | const isOverLeft = todayTranslateX < translateX; 22 | const isOverRight = todayTranslateX > translateX + viewWidth; 23 | return isOverLeft || isOverRight ? 'block' : 'none'; 24 | }, [todayTranslateX, translateX, viewWidth]); 25 | const handleClick = useCallback(() => { 26 | store.scrollToToday(); 27 | }, [store]); 28 | return ( 29 | 40 | ); 41 | }; 42 | export default observer(TimeIndicator); 43 | -------------------------------------------------------------------------------- /src/components/today/index.less: -------------------------------------------------------------------------------- 1 | @import '../../style/themes/index'; 2 | 3 | .@{gantt-prefix}-today { 4 | position: absolute; 5 | top: 0; 6 | background: #3f51b5; 7 | width: 30px; 8 | height: 30px; 9 | text-align: center; 10 | line-height: 30px; 11 | border-radius: 50%; 12 | font-size: 12px; 13 | color: #ffffff; 14 | pointer-events: none; 15 | } 16 | 17 | .@{gantt-prefix}-today_line { 18 | width: 1px; 19 | background: #3f51b5; 20 | margin-left: 15px; 21 | } -------------------------------------------------------------------------------- /src/components/today/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import Context from '../../context'; 4 | import './index.less'; 5 | 6 | const Today: React.FC = () => { 7 | const { store, prefixCls } = useContext(Context); 8 | return ( 9 |
15 | 今日 16 |
22 |
23 | ); 24 | }; 25 | export default observer(Today); 26 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ROW_HEIGHT = 28; 2 | export const HEADER_HEIGHT = 56; 3 | export const BAR_HEIGHT = 8; 4 | export const TOP_PADDING = 0; 5 | export const TABLE_INDENT = 30; 6 | // 图表最小比例 7 | export const MIN_VIEW_RATE = 0.5; 8 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import GanttStore from './store'; 3 | import { DefaultRecordType, Gantt } from './types'; 4 | 5 | export interface GanttContext { 6 | prefixCls: string; 7 | store: GanttStore; 8 | getBarColor?: ( 9 | record: Gantt.Record 10 | ) => { backgroundColor: string; borderColor: string }; 11 | showBackToday: boolean; 12 | showUnitSwitch: boolean; 13 | onRow?: { 14 | onClick: (record: Gantt.Record) => void; 15 | }; 16 | tableIndent: number; 17 | barHeight: number; 18 | expandIcon?: ({ 19 | level, 20 | collapsed, 21 | onClick, 22 | }: { 23 | level: number; 24 | collapsed: boolean; 25 | onClick: (event: React.MouseEvent) => void; 26 | }) => React.ReactNode; 27 | renderBar?: ( 28 | barInfo: Gantt.Bar, 29 | { width, height }: { width: number; height: number } 30 | ) => React.ReactNode; 31 | renderInvalidBar?: ( 32 | element: React.ReactNode, 33 | barInfo: Gantt.Bar 34 | ) => React.ReactNode; 35 | renderGroupBar?: ( 36 | barInfo: Gantt.Bar, 37 | { width, height }: { width: number; height: number } 38 | ) => React.ReactNode; 39 | renderBarThumb?: ( 40 | item: Gantt.Record, 41 | type: 'left' | 'right' 42 | ) => React.ReactNode; 43 | onBarClick?: (record: Gantt.Record) => void; 44 | tableCollapseAble: boolean; 45 | scrollTop: boolean | React.CSSProperties; 46 | } 47 | const context = createContext({} as GanttContext); 48 | export default context; 49 | -------------------------------------------------------------------------------- /src/hooks/useDragResize.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useState } from 'react'; 2 | import { usePersistFn } from 'ahooks'; 3 | 4 | export default function useDragResize( 5 | handleResize: ({ width }: { width: number }) => void, 6 | { 7 | initSize, 8 | minWidth: minWidthConfig, 9 | maxWidth: maxWidthConfig, 10 | }: { 11 | initSize: { 12 | width: number; 13 | }; 14 | minWidth?: number; 15 | maxWidth?: number; 16 | } 17 | ): [(event: React.MouseEvent) => void, boolean] { 18 | const [resizing, setResizing] = useState(false); 19 | const positionRef = useRef({ 20 | left: 0, 21 | }); 22 | const initSizeRef = useRef(initSize); 23 | const handleMouseMove = usePersistFn(async (event: MouseEvent) => { 24 | const distance = event.clientX - positionRef.current.left; 25 | let width = initSizeRef.current.width + distance; 26 | if (minWidthConfig !== undefined) { 27 | width = Math.max(width, minWidthConfig); 28 | } 29 | if (maxWidthConfig !== undefined) { 30 | width = Math.min(width, maxWidthConfig); 31 | } 32 | handleResize({ width }); 33 | }); 34 | const handleMouseUp = useCallback(() => { 35 | window.removeEventListener('mousemove', handleMouseMove); 36 | window.removeEventListener('mouseup', handleMouseUp); 37 | setResizing(false); 38 | }, [handleMouseMove]); 39 | const handleMouseDown = useCallback( 40 | (event: React.MouseEvent) => { 41 | positionRef.current.left = event.clientX; 42 | initSizeRef.current = initSize; 43 | window.addEventListener('mousemove', handleMouseMove); 44 | window.addEventListener('mouseup', handleMouseUp); 45 | setResizing(true); 46 | }, 47 | [handleMouseMove, handleMouseUp, initSize] 48 | ); 49 | return [handleMouseDown, resizing]; 50 | } 51 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import GanttComponent from './Gantt'; 2 | import { GanttProps, GanttRef } from './Gantt'; 3 | import { Gantt } from './types'; 4 | 5 | export { GanttProps, Gantt, GanttRef }; 6 | export default GanttComponent; 7 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { createRef } from 'react'; 2 | import { observable, computed, action, runInAction } from 'mobx'; 3 | import debounce from 'lodash/debounce'; 4 | import find from 'lodash/find'; 5 | import throttle from 'lodash/throttle'; 6 | import dayjs, { Dayjs } from 'dayjs'; 7 | import weekOfYear from 'dayjs/plugin/weekOfYear'; 8 | import quarterOfYear from 'dayjs/plugin/quarterOfYear'; 9 | import isBetween from 'dayjs/plugin/isBetween'; 10 | import advancedFormat from 'dayjs/plugin/advancedFormat'; 11 | import isLeapYear from 'dayjs/plugin/isLeapYear'; 12 | import weekday from 'dayjs/plugin/weekday'; 13 | import { Gantt } from './types'; 14 | import { HEADER_HEIGHT, MIN_VIEW_RATE, TOP_PADDING } from './constants'; 15 | import { flattenDeep, transverseData } from './utils'; 16 | import { GanttProps } from './Gantt'; 17 | 18 | dayjs.extend(weekday); 19 | dayjs.extend(weekOfYear); 20 | dayjs.extend(quarterOfYear); 21 | dayjs.extend(advancedFormat); 22 | dayjs.extend(isBetween); 23 | dayjs.extend(isLeapYear); 24 | export const ONE_DAY_MS = 86400000; 25 | // 视图日视图、周视图、月视图、季视图、年视图 26 | export const viewTypeList: Gantt.SightConfig[] = [ 27 | { 28 | type: 'day', 29 | label: '日', 30 | value: 2880, 31 | }, 32 | { 33 | type: 'week', 34 | label: '周', 35 | value: 3600, 36 | }, 37 | { 38 | type: 'month', 39 | label: '月', 40 | value: 14400, 41 | }, 42 | { 43 | type: 'quarter', 44 | label: '季', 45 | value: 86400, 46 | }, 47 | { 48 | type: 'halfYear', 49 | label: '年', 50 | value: 115200, 51 | }, 52 | ]; 53 | function isRestDay(date: string) { 54 | return [0, 6].includes(dayjs(date).weekday()); 55 | } 56 | class GanttStore { 57 | constructor({ rowHeight }: { rowHeight: number }) { 58 | this.width = 1320; 59 | this.height = 418; 60 | const sightConfig = viewTypeList[0]; 61 | const translateX = 62 | dayjs(this.getStartDate()).valueOf() / (sightConfig.value * 1000); 63 | const bodyWidth = this.width; 64 | const viewWidth = 704; 65 | const tableWidth = 500; 66 | this.viewWidth = viewWidth; 67 | this.tableWidth = tableWidth; 68 | this.translateX = translateX; 69 | this.sightConfig = sightConfig; 70 | this.bodyWidth = bodyWidth; 71 | this.rowHeight = rowHeight; 72 | } 73 | 74 | _wheelTimer: number | undefined; 75 | 76 | scrollTimer: number | undefined; 77 | 78 | @observable data: Gantt.Item[] = []; 79 | 80 | @observable originData: Gantt.Record[] = []; 81 | 82 | @observable columns: Gantt.Column[] = []; 83 | 84 | @observable dependencies: Gantt.Dependence[] = []; 85 | 86 | @observable scrolling = false; 87 | 88 | @observable scrollTop = 0; 89 | 90 | @observable collapse = false; 91 | 92 | @observable tableWidth: number; 93 | 94 | @observable viewWidth: number; 95 | 96 | @observable width: number; 97 | 98 | @observable height: number; 99 | 100 | @observable bodyWidth: number; 101 | 102 | @observable translateX: number; 103 | 104 | @observable sightConfig: Gantt.SightConfig; 105 | 106 | @observable showSelectionIndicator: boolean = false; 107 | 108 | @observable selectionIndicatorTop: number = 0; 109 | 110 | @observable dragging: Gantt.Bar | null = null; 111 | 112 | @observable draggingType: Gantt.MoveType | null = null; 113 | 114 | gestureKeyPress: boolean = false; 115 | 116 | mainElementRef = createRef(); 117 | 118 | chartElementRef = createRef(); 119 | 120 | isPointerPress: boolean = false; 121 | 122 | startDateKey: string = 'startDate'; 123 | 124 | endDateKey: string = 'endDate'; 125 | 126 | autoScrollPos: number = 0; 127 | 128 | clientX: number = 0; 129 | 130 | rowHeight: number; 131 | 132 | onUpdate: GanttProps['onUpdate'] = () => Promise.resolve(true); 133 | 134 | isRestDay = isRestDay; 135 | 136 | getStartDate() { 137 | return dayjs().subtract(10, 'day').toString(); 138 | } 139 | 140 | setIsRestDay(func: (date: string) => boolean) { 141 | this.isRestDay = func || isRestDay; 142 | } 143 | 144 | @action 145 | setData(data: Gantt.Record[], startDateKey: string, endDateKey: string) { 146 | this.startDateKey = startDateKey; 147 | this.endDateKey = endDateKey; 148 | this.originData = data; 149 | this.data = transverseData(data, startDateKey, endDateKey); 150 | } 151 | 152 | @action 153 | toggleCollapse() { 154 | if (this.tableWidth > 0) { 155 | this.tableWidth = 0; 156 | this.viewWidth = this.width - this.tableWidth; 157 | } else { 158 | this.initWidth(); 159 | } 160 | } 161 | 162 | @action 163 | setRowCollapse(item: Gantt.Item, collapsed: boolean) { 164 | item.collapsed = collapsed; 165 | // this.barList = this.getBarList(); 166 | } 167 | 168 | @action 169 | setOnUpdate(onUpdate: GanttProps['onUpdate']) { 170 | this.onUpdate = onUpdate; 171 | } 172 | 173 | @action 174 | setColumns(columns: Gantt.Column[]) { 175 | this.columns = columns; 176 | } 177 | @action 178 | setDependencies(dependencies: Gantt.Dependence[]) { 179 | this.dependencies = dependencies; 180 | } 181 | 182 | @action 183 | handlePanMove(translateX: number) { 184 | this.scrolling = true; 185 | this.setTranslateX(translateX); 186 | } 187 | @action 188 | handlePanEnd() { 189 | this.scrolling = false; 190 | } 191 | @action syncSize(size: { width?: number; height?: number }) { 192 | if (!size.height || !size.width) { 193 | return; 194 | } 195 | const { width, height } = size; 196 | if (this.height !== height) { 197 | this.height = height; 198 | } 199 | if (this.width !== width) { 200 | this.width = width; 201 | this.initWidth(); 202 | } 203 | } 204 | 205 | @action handleResizeTableWidth(width: number) { 206 | this.tableWidth = width; 207 | this.viewWidth = this.width - this.tableWidth; 208 | // const tableMinWidth = 200; 209 | // const chartMinWidth = 200; 210 | // if (this.tableWidth + increase >= tableMinWidth && this.viewWidth - increase >= chartMinWidth) { 211 | // this.tableWidth += increase; 212 | // this.viewWidth -= increase; 213 | // } 214 | } 215 | 216 | @action initWidth() { 217 | this.tableWidth = 500; 218 | this.viewWidth = this.width - this.tableWidth; 219 | // 表盘宽度不能小于总宽度38% 220 | if (this.viewWidth < MIN_VIEW_RATE * this.width) { 221 | this.viewWidth = MIN_VIEW_RATE * this.width; 222 | this.tableWidth = this.width - this.viewWidth; 223 | } 224 | 225 | // 图表宽度不能小于 200 226 | if (this.viewWidth < 200) { 227 | this.viewWidth = 200; 228 | this.tableWidth = this.width - this.viewWidth; 229 | } 230 | } 231 | @action 232 | setTranslateX(translateX: number) { 233 | this.translateX = Math.max(translateX, 0); 234 | } 235 | @action switchSight(type: Gantt.Sight) { 236 | const target = find(viewTypeList, { type }); 237 | if (target) { 238 | this.sightConfig = target; 239 | this.setTranslateX( 240 | dayjs(this.getStartDate()).valueOf() / (target.value * 1000) 241 | ); 242 | } 243 | } 244 | 245 | @action scrollToToday() { 246 | const translateX = this.todayTranslateX - this.viewWidth / 2; 247 | this.setTranslateX(translateX); 248 | } 249 | 250 | getTranslateXByDate(date: string) { 251 | return dayjs(date).startOf('day').valueOf() / this.pxUnitAmp; 252 | } 253 | 254 | @computed get todayTranslateX() { 255 | return dayjs().startOf('day').valueOf() / this.pxUnitAmp; 256 | } 257 | 258 | @computed get scrollBarWidth() { 259 | const MIN_WIDTH = 30; 260 | return Math.max((this.viewWidth / this.scrollWidth) * 160, MIN_WIDTH); 261 | } 262 | 263 | @computed get scrollLeft() { 264 | const rate = this.viewWidth / this.scrollWidth; 265 | const curDate = dayjs(this.translateAmp).toString(); 266 | // 默认滚动条在中间 267 | const half = (this.viewWidth - this.scrollBarWidth) / 2; 268 | const viewScrollLeft = 269 | half + 270 | rate * 271 | (this.getTranslateXByDate(curDate) - 272 | this.getTranslateXByDate(this.getStartDate())); 273 | return Math.min( 274 | Math.max(viewScrollLeft, 0), 275 | this.viewWidth - this.scrollBarWidth 276 | ); 277 | } 278 | 279 | @computed get scrollWidth() { 280 | // TODO 待研究 281 | // 最小宽度 282 | const init = this.viewWidth + 200; 283 | return Math.max( 284 | Math.abs( 285 | this.viewWidth + 286 | this.translateX - 287 | this.getTranslateXByDate(this.getStartDate()) 288 | ), 289 | init 290 | ); 291 | } 292 | 293 | // 内容区滚动高度 294 | @computed get bodyClientHeight() { 295 | // 1是边框 296 | return this.height - HEADER_HEIGHT - 1; 297 | } 298 | 299 | @computed get getColumnsWidth(): number[] { 300 | const totalColumnWidth = this.columns.reduce( 301 | (width, item) => width + (item.width || 0), 302 | 0 303 | ); 304 | const totalFlex = this.columns.reduce( 305 | (total, item) => total + (item.width ? 0 : item.flex || 1), 306 | 0 307 | ); 308 | const restWidth = this.tableWidth - totalColumnWidth; 309 | return this.columns.map((column) => { 310 | if (column.width) { 311 | return column.width; 312 | } 313 | if (column.flex) { 314 | return restWidth * (column.flex / totalFlex); 315 | } 316 | return restWidth * (1 / totalFlex); 317 | }); 318 | } 319 | 320 | // 内容区滚动区域域高度 321 | @computed get bodyScrollHeight() { 322 | let height = this.getBarList.length * this.rowHeight + TOP_PADDING; 323 | if (height < this.bodyClientHeight) { 324 | height = this.bodyClientHeight; 325 | } 326 | return height; 327 | } 328 | 329 | // 1px对应的毫秒数 330 | @computed get pxUnitAmp() { 331 | return this.sightConfig.value * 1000; 332 | } 333 | 334 | /** 335 | * 当前开始时间毫秒数 336 | */ 337 | @computed get translateAmp() { 338 | const { translateX } = this; 339 | return this.pxUnitAmp * translateX; 340 | } 341 | 342 | getDurationAmp() { 343 | const clientWidth = this.viewWidth; 344 | return this.pxUnitAmp * clientWidth; 345 | } 346 | getPosition = (startDate?: string, endDate?: string) => { 347 | if (!startDate || !endDate) { 348 | return { 349 | translateX: 0, 350 | width: 0, 351 | }; 352 | } 353 | const { pxUnitAmp } = this; 354 | // 最小宽度 355 | const minStamp = 11 * pxUnitAmp; 356 | const valid = startDate && endDate; 357 | let startAmp = dayjs(startDate || 0) 358 | .startOf('day') 359 | .valueOf(); 360 | let endAmp = dayjs(endDate || 0) 361 | .endOf('day') 362 | .valueOf(); 363 | 364 | // 开始结束日期相同默认一天 365 | if (Math.abs(endAmp - startAmp) < minStamp) { 366 | startAmp = dayjs(startDate || 0) 367 | .startOf('day') 368 | .valueOf(); 369 | endAmp = dayjs(endDate || 0) 370 | .endOf('day') 371 | .add(minStamp, 'millisecond') 372 | .valueOf(); 373 | } 374 | const width = valid ? (endAmp - startAmp) / pxUnitAmp : 0; 375 | const translateX = valid ? startAmp / pxUnitAmp : 0; 376 | 377 | return { 378 | translateX, 379 | width, 380 | }; 381 | }; 382 | 383 | getWidthByDate = (startDate: Dayjs, endDate: Dayjs) => 384 | (endDate.valueOf() - startDate.valueOf()) / this.pxUnitAmp; 385 | 386 | getMajorList(): Gantt.Major[] { 387 | const majorFormatMap: { [key in Gantt.Sight]: string } = { 388 | day: 'YYYY年MM月', 389 | week: 'YYYY年MM月', 390 | month: 'YYYY年', 391 | quarter: 'YYYY年', 392 | halfYear: 'YYYY年', 393 | }; 394 | const { translateAmp } = this; 395 | const endAmp = translateAmp + this.getDurationAmp(); 396 | const { type } = this.sightConfig; 397 | const format = majorFormatMap[type]; 398 | 399 | const getNextDate = (start: Dayjs) => { 400 | if (type === 'day' || type === 'week') { 401 | return start.add(1, 'month'); 402 | } 403 | return start.add(1, 'year'); 404 | }; 405 | 406 | const getStart = (date: Dayjs) => { 407 | if (type === 'day' || type === 'week') { 408 | return date.startOf('month'); 409 | } 410 | return date.startOf('year'); 411 | }; 412 | 413 | const getEnd = (date: Dayjs) => { 414 | if (type === 'day' || type === 'week') { 415 | return date.endOf('month'); 416 | } 417 | return date.endOf('year'); 418 | }; 419 | 420 | // 初始化当前时间 421 | let curDate = dayjs(translateAmp); 422 | const dates: Gantt.MajorAmp[] = []; 423 | 424 | // 对可视区域内的时间进行迭代 425 | while (curDate.isBetween(translateAmp - 1, endAmp + 1)) { 426 | const majorKey = curDate.format(format); 427 | 428 | let start = curDate; 429 | const end = getEnd(start); 430 | if (dates.length !== 0) { 431 | start = getStart(curDate); 432 | } 433 | dates.push({ 434 | label: majorKey, 435 | startDate: start, 436 | endDate: end, 437 | }); 438 | 439 | // 获取下次迭代的时间 440 | start = getStart(curDate); 441 | curDate = getNextDate(start); 442 | } 443 | 444 | return this.majorAmp2Px(dates); 445 | } 446 | 447 | majorAmp2Px(ampList: Gantt.MajorAmp[]) { 448 | const { pxUnitAmp } = this; 449 | const list = ampList.map((item) => { 450 | const { startDate } = item; 451 | const { endDate } = item; 452 | const { label } = item; 453 | const left = startDate.valueOf() / pxUnitAmp; 454 | const width = (endDate.valueOf() - startDate.valueOf()) / pxUnitAmp; 455 | 456 | return { 457 | label, 458 | left, 459 | width, 460 | key: startDate.format('YYYY-MM-DD HH:mm:ss'), 461 | }; 462 | }); 463 | return list; 464 | } 465 | 466 | getMinorList(): Gantt.Minor[] { 467 | const minorFormatMap = { 468 | day: 'YYYY-MM-D', 469 | week: 'YYYY-w周', 470 | month: 'YYYY-MM月', 471 | quarter: 'YYYY-第Q季', 472 | halfYear: 'YYYY-', 473 | }; 474 | const fstHalfYear = [0, 1, 2, 3, 4, 5]; 475 | 476 | const startAmp = this.translateAmp; 477 | const endAmp = startAmp + this.getDurationAmp(); 478 | const format = minorFormatMap[this.sightConfig.type]; 479 | 480 | const getNextDate = (start: Dayjs) => { 481 | const map = { 482 | day() { 483 | return start.add(1, 'day'); 484 | }, 485 | week() { 486 | return start.add(1, 'week'); 487 | }, 488 | month() { 489 | return start.add(1, 'month'); 490 | }, 491 | quarter() { 492 | return start.add(1, 'quarter'); 493 | }, 494 | halfYear() { 495 | return start.add(6, 'month'); 496 | }, 497 | }; 498 | 499 | return map[this.sightConfig.type](); 500 | }; 501 | const setStart = (date: Dayjs) => { 502 | const map = { 503 | day() { 504 | return date.startOf('day'); 505 | }, 506 | week() { 507 | return date.weekday(1).hour(0).minute(0).second(0); 508 | }, 509 | month() { 510 | return date.startOf('month'); 511 | }, 512 | quarter() { 513 | return date.startOf('quarter'); 514 | }, 515 | halfYear() { 516 | if (fstHalfYear.includes(date.month())) { 517 | return date.month(0).startOf('month'); 518 | } 519 | return date.month(6).startOf('month'); 520 | }, 521 | }; 522 | 523 | return map[this.sightConfig.type](); 524 | }; 525 | const setEnd = (start: Dayjs) => { 526 | const map = { 527 | day() { 528 | return start.endOf('day'); 529 | }, 530 | week() { 531 | return start.weekday(7).hour(23).minute(59).second(59); 532 | }, 533 | month() { 534 | return start.endOf('month'); 535 | }, 536 | quarter() { 537 | return start.endOf('quarter'); 538 | }, 539 | halfYear() { 540 | if (fstHalfYear.includes(start.month())) { 541 | return start.month(5).endOf('month'); 542 | } 543 | return start.month(11).endOf('month'); 544 | }, 545 | }; 546 | 547 | return map[this.sightConfig.type](); 548 | }; 549 | const getMinorKey = (date: Dayjs) => { 550 | if (this.sightConfig.type === 'halfYear') { 551 | return ( 552 | date.format(format) + 553 | (fstHalfYear.includes(date.month()) ? '上半年' : '下半年') 554 | ); 555 | } 556 | 557 | return date.format(format); 558 | }; 559 | 560 | // 初始化当前时间 561 | let curDate = dayjs(startAmp); 562 | const dates: Gantt.MinorAmp[] = []; 563 | while (curDate.isBetween(startAmp - 1, endAmp + 1)) { 564 | const minorKey = getMinorKey(curDate); 565 | const start = setStart(curDate); 566 | const end = setEnd(start); 567 | dates.push({ 568 | label: minorKey.split('-').pop() as string, 569 | startDate: start, 570 | endDate: end, 571 | }); 572 | curDate = getNextDate(start); 573 | } 574 | 575 | return this.minorAmp2Px(dates); 576 | } 577 | 578 | startXRectBar = (startX: number) => { 579 | let date = dayjs(startX * this.pxUnitAmp); 580 | const dayRect = () => { 581 | const stAmp = date.startOf('day'); 582 | const endAmp = date.endOf('day'); 583 | // @ts-ignore 584 | const left = stAmp / this.pxUnitAmp; 585 | // @ts-ignore 586 | const width = (endAmp - stAmp) / this.pxUnitAmp; 587 | 588 | return { 589 | left, 590 | width, 591 | }; 592 | }; 593 | const weekRect = () => { 594 | if (date.weekday() === 0) { 595 | date = date.add(-1, 'week'); 596 | } 597 | const left = date.weekday(1).startOf('day').valueOf() / this.pxUnitAmp; 598 | const width = (7 * 24 * 60 * 60 * 1000 - 1000) / this.pxUnitAmp; 599 | 600 | return { 601 | left, 602 | width, 603 | }; 604 | }; 605 | const monthRect = () => { 606 | const stAmp = date.startOf('month').valueOf(); 607 | const endAmp = date.endOf('month').valueOf(); 608 | const left = stAmp / this.pxUnitAmp; 609 | const width = (endAmp - stAmp) / this.pxUnitAmp; 610 | 611 | return { 612 | left, 613 | width, 614 | }; 615 | }; 616 | 617 | const map = { 618 | day: dayRect, 619 | week: weekRect, 620 | month: weekRect, 621 | quarter: monthRect, 622 | halfYear: monthRect, 623 | }; 624 | 625 | return map[this.sightConfig.type](); 626 | }; 627 | 628 | minorAmp2Px(ampList: Gantt.MinorAmp[]): Gantt.Minor[] { 629 | const { pxUnitAmp } = this; 630 | const list = ampList.map((item) => { 631 | const startDate = item.startDate; 632 | const endDate = item.endDate; 633 | 634 | const { label } = item; 635 | const left = startDate.valueOf() / pxUnitAmp; 636 | const width = (endDate.valueOf() - startDate.valueOf()) / pxUnitAmp; 637 | 638 | let isWeek = false; 639 | if (this.sightConfig.type === 'day') { 640 | isWeek = this.isRestDay(startDate.toString()); 641 | } 642 | return { 643 | label, 644 | left, 645 | width, 646 | isWeek, 647 | key: startDate.format('YYYY-MM-DD HH:mm:ss'), 648 | }; 649 | }); 650 | return list; 651 | } 652 | 653 | getTaskBarThumbVisible(barInfo: Gantt.Bar) { 654 | const { width, translateX: barTranslateX, invalidDateRange } = barInfo; 655 | if (invalidDateRange) { 656 | return false; 657 | } 658 | const rightSide = this.translateX + this.viewWidth; 659 | const right = barTranslateX; 660 | 661 | return barTranslateX + width < this.translateX || right - rightSide > 0; 662 | } 663 | 664 | scrollToBar(barInfo: Gantt.Bar, type: 'left' | 'right') { 665 | const { translateX: barTranslateX, width } = barInfo; 666 | const translateX1 = this.translateX + this.viewWidth / 2; 667 | const translateX2 = barTranslateX + width; 668 | 669 | const diffX = Math.abs(translateX2 - translateX1); 670 | let translateX = this.translateX + diffX; 671 | 672 | if (type === 'left') { 673 | translateX = this.translateX - diffX; 674 | } 675 | 676 | this.setTranslateX(translateX); 677 | } 678 | 679 | @computed get getBarList(): Gantt.Bar[] { 680 | const { pxUnitAmp, data } = this; 681 | // 最小宽度 682 | const minStamp = 11 * pxUnitAmp; 683 | // TODO 去除高度读取 684 | const height = 8; 685 | const baseTop = TOP_PADDING + this.rowHeight / 2 - height / 2; 686 | const topStep = this.rowHeight; 687 | 688 | const dateTextFormat = (startX: number) => 689 | dayjs(startX * pxUnitAmp).format('YYYY-MM-DD'); 690 | const flattenData = flattenDeep(data); 691 | const barList = flattenData.map((item, index) => { 692 | const valid = item.startDate && item.endDate; 693 | let startAmp = dayjs(item.startDate || 0) 694 | .startOf('day') 695 | .valueOf(); 696 | let endAmp = dayjs(item.endDate || 0) 697 | .endOf('day') 698 | .valueOf(); 699 | 700 | // 开始结束日期相同默认一天 701 | if (Math.abs(endAmp - startAmp) < minStamp) { 702 | startAmp = dayjs(item.startDate || 0) 703 | .startOf('day') 704 | .valueOf(); 705 | endAmp = dayjs(item.endDate || 0) 706 | .endOf('day') 707 | .add(minStamp, 'millisecond') 708 | .valueOf(); 709 | } 710 | 711 | const width = valid ? (endAmp - startAmp) / pxUnitAmp : 0; 712 | const translateX = valid ? startAmp / pxUnitAmp : 0; 713 | const translateY = baseTop + index * topStep; 714 | const { _parent } = item; 715 | const bar: Gantt.Bar = { 716 | key: item.key, 717 | task: item, 718 | record: item.record, 719 | translateX, 720 | translateY, 721 | width, 722 | label: item.content, 723 | stepGesture: 'end', // start(开始)、moving(移动)、end(结束) 724 | invalidDateRange: !item.endDate || !item.startDate, // 是否为有效时间区间 725 | dateTextFormat, 726 | loading: false, 727 | _group: item.group, 728 | _groupWidthSelf: item.groupWidthSelf, 729 | _collapsed: item.collapsed, // 是否折叠 730 | _depth: item._depth as number, // 表示子节点深度 731 | _index: item._index, // 任务下标位置 732 | _parent, // 原任务数据 733 | _childrenCount: !item.children ? 0 : item.children.length, // 子任务 734 | }; 735 | item._bar = bar; 736 | return bar; 737 | }); 738 | // 进行展开扁平 739 | return observable(barList); 740 | } 741 | 742 | @action 743 | handleWheel = (event: WheelEvent) => { 744 | if (event.deltaX !== 0) { 745 | event.preventDefault(); 746 | event.stopPropagation(); 747 | } 748 | if (this._wheelTimer) clearTimeout(this._wheelTimer); 749 | // 水平滚动 750 | if (Math.abs(event.deltaX) > 0) { 751 | this.scrolling = true; 752 | this.setTranslateX(this.translateX + event.deltaX); 753 | } 754 | this._wheelTimer = window.setTimeout(() => { 755 | this.scrolling = false; 756 | }, 100); 757 | }; 758 | 759 | handleScroll = (event: React.UIEvent) => { 760 | const { scrollTop } = event.currentTarget; 761 | this.scrollY(scrollTop); 762 | }; 763 | 764 | scrollY = throttle((scrollTop: number) => { 765 | this.scrollTop = scrollTop; 766 | }, 100); 767 | 768 | // 虚拟滚动 769 | @computed get getVisibleRows() { 770 | const visibleHeight = this.bodyClientHeight; 771 | // 多渲染几个,减少空白 772 | const visibleRowCount = Math.ceil(visibleHeight / this.rowHeight) + 10; 773 | 774 | const start = Math.max(Math.ceil(this.scrollTop / this.rowHeight) - 5, 0); 775 | return { 776 | start, 777 | count: visibleRowCount, 778 | }; 779 | } 780 | 781 | handleMouseMove = debounce((event) => { 782 | if (!this.isPointerPress) { 783 | this.showSelectionBar(event); 784 | } 785 | }, 5); 786 | 787 | handleMouseLeave() { 788 | this.showSelectionIndicator = false; 789 | } 790 | 791 | @action 792 | showSelectionBar(event: MouseEvent) { 793 | const scrollTop = this.mainElementRef.current?.scrollTop || 0; 794 | const { top } = this.mainElementRef.current?.getBoundingClientRect() || { 795 | top: 0, 796 | }; 797 | // 内容区高度 798 | const contentHeight = this.getBarList.length * this.rowHeight; 799 | const offsetY = event.clientY - top + scrollTop; 800 | if (offsetY - contentHeight > TOP_PADDING) { 801 | this.showSelectionIndicator = false; 802 | } else { 803 | const top = 804 | Math.floor((offsetY - TOP_PADDING) / this.rowHeight) * this.rowHeight + 805 | TOP_PADDING; 806 | this.showSelectionIndicator = true; 807 | this.selectionIndicatorTop = top; 808 | } 809 | } 810 | 811 | getHovered = (top: number) => { 812 | const baseTop = top - (top % this.rowHeight); 813 | const isShow = 814 | this.selectionIndicatorTop >= baseTop && 815 | this.selectionIndicatorTop <= baseTop + this.rowHeight; 816 | return isShow; 817 | }; 818 | 819 | @action 820 | handleDragStart(barInfo: Gantt.Bar, type: Gantt.MoveType) { 821 | this.dragging = barInfo; 822 | this.draggingType = type; 823 | barInfo.stepGesture = 'start'; 824 | this.isPointerPress = true; 825 | } 826 | 827 | @action 828 | handleDragEnd() { 829 | if (this.dragging) { 830 | this.dragging.stepGesture = 'end'; 831 | this.dragging = null; 832 | } 833 | this.draggingType = null; 834 | this.isPointerPress = false; 835 | } 836 | 837 | @action 838 | handleInvalidBarLeave() { 839 | this.handleDragEnd(); 840 | } 841 | 842 | @action 843 | handleInvalidBarHover(barInfo: Gantt.Bar, left: number, width: number) { 844 | barInfo.translateX = left; 845 | barInfo.width = width; 846 | this.handleDragStart(barInfo, 'create'); 847 | } 848 | 849 | @action 850 | handleInvalidBarDragStart(barInfo: Gantt.Bar) { 851 | barInfo.stepGesture = 'moving'; 852 | } 853 | 854 | @action 855 | handleInvalidBarDragEnd( 856 | barInfo: Gantt.Bar, 857 | oldSize: { width: number; x: number } 858 | ) { 859 | barInfo.invalidDateRange = false; 860 | this.handleDragEnd(); 861 | this.updateTaskDate(barInfo, oldSize, 'create'); 862 | } 863 | 864 | @action 865 | updateBarSize( 866 | barInfo: Gantt.Bar, 867 | { width, x }: { width: number; x: number } 868 | ) { 869 | barInfo.width = width; 870 | barInfo.translateX = Math.max(x, 0); 871 | barInfo.stepGesture = 'moving'; 872 | } 873 | getMovedDay(ms: number): number { 874 | return Math.round(ms / ONE_DAY_MS); 875 | } 876 | /** 877 | * 更新时间 878 | */ 879 | @action 880 | async updateTaskDate( 881 | barInfo: Gantt.Bar, 882 | oldSize: { width: number; x: number }, 883 | type: 'move' | 'left' | 'right' | 'create' 884 | ) { 885 | const { translateX, width, task, record } = barInfo; 886 | const oldStartDate = barInfo.task.startDate; 887 | const oldEndDate = barInfo.task.endDate; 888 | let startDate = oldStartDate; 889 | let endDate = oldEndDate; 890 | 891 | if (type === 'move') { 892 | const moveTime = this.getMovedDay( 893 | (translateX - oldSize.x) * this.pxUnitAmp 894 | ); 895 | // 移动,只根据移动距离偏移 896 | startDate = dayjs(oldStartDate) 897 | .add(moveTime, 'day') 898 | .format('YYYY-MM-DD HH:mm:ss'); 899 | endDate = dayjs(oldEndDate) 900 | .add(moveTime, 'day') 901 | .format('YYYY-MM-DD HH:mm:ss'); 902 | } else if (type === 'left') { 903 | const moveTime = this.getMovedDay( 904 | (translateX - oldSize.x) * this.pxUnitAmp 905 | ); 906 | // 左侧移动,只改变开始时间 907 | startDate = dayjs(oldStartDate) 908 | .add(moveTime, 'day') 909 | .format('YYYY-MM-DD HH:mm:ss'); 910 | } else if (type === 'right') { 911 | const moveTime = this.getMovedDay( 912 | (width - oldSize.width) * this.pxUnitAmp 913 | ); 914 | // 右侧移动,只改变结束时间 915 | endDate = dayjs(oldEndDate) 916 | .add(moveTime, 'day') 917 | .format('YYYY-MM-DD HH:mm:ss'); 918 | } else if (type === 'create') { 919 | //创建 920 | startDate = dayjs(translateX * this.pxUnitAmp).format( 921 | 'YYYY-MM-DD HH:mm:ss' 922 | ); 923 | endDate = dayjs((translateX + width) * this.pxUnitAmp) 924 | .subtract(1) 925 | .hour(23) 926 | .minute(59) 927 | .second(59) 928 | .format('YYYY-MM-DD HH:mm:ss'); 929 | } 930 | if (startDate === oldStartDate && endDate === oldEndDate) { 931 | return; 932 | } 933 | runInAction(() => { 934 | barInfo.loading = true; 935 | }); 936 | const success = await this.onUpdate(record, startDate, endDate); 937 | if (success) { 938 | runInAction(() => { 939 | task.startDate = startDate; 940 | task.endDate = endDate; 941 | }); 942 | } else { 943 | barInfo.width = oldSize.width; 944 | barInfo.translateX = oldSize.x; 945 | } 946 | } 947 | } 948 | 949 | export default GanttStore; 950 | -------------------------------------------------------------------------------- /src/style/index.less: -------------------------------------------------------------------------------- 1 | @import './themes/index'; 2 | -------------------------------------------------------------------------------- /src/style/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | -------------------------------------------------------------------------------- /src/style/themes/default.less: -------------------------------------------------------------------------------- 1 | @gantt-prefix: gantt; 2 | -------------------------------------------------------------------------------- /src/style/themes/index.less: -------------------------------------------------------------------------------- 1 | @import './default.less'; 2 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Dayjs } from 'dayjs'; 2 | export type DefaultRecordType = Record; 3 | export namespace Gantt { 4 | export interface Major { 5 | width: number; 6 | left: number; 7 | label: string; 8 | key: string; 9 | } 10 | export interface MajorAmp { 11 | label: string; 12 | startDate: Dayjs; 13 | endDate: Dayjs; 14 | } 15 | export interface Minor { 16 | width: number; 17 | left: number; 18 | label: string; 19 | isWeek: boolean; 20 | key: string; 21 | } 22 | export interface MinorAmp { 23 | label: string; 24 | startDate: Dayjs; 25 | endDate: Dayjs; 26 | } 27 | export type Sight = 'day' | 'week' | 'month' | 'quarter' | 'halfYear'; 28 | export type MoveType = 'left' | 'right' | 'move' | 'create'; 29 | export interface SightConfig { 30 | type: Sight; 31 | label: string; 32 | value: number; 33 | } 34 | export interface Bar { 35 | key: React.Key; 36 | label: string; 37 | width: number; 38 | translateX: number; 39 | translateY: number; 40 | stepGesture: string; 41 | invalidDateRange: boolean; 42 | dateTextFormat: (startX: number) => string; 43 | task: Item; 44 | record: Record; 45 | loading: boolean; 46 | _group?: boolean; 47 | _groupWidthSelf?: boolean; 48 | _collapsed: boolean; 49 | _depth: number; 50 | _index?: number; 51 | _childrenCount: number; 52 | _parent?: Item; 53 | } 54 | export interface Item { 55 | record: Record; 56 | key: React.Key; 57 | startDate: string | null; 58 | endDate: string | null; 59 | content: string; 60 | collapsed: boolean; 61 | group?: boolean; 62 | /** group使用自身时间做计算 */ 63 | groupWidthSelf?: boolean; 64 | children?: Item[]; 65 | _parent?: Item; 66 | _bar?: Bar; 67 | _depth?: number; 68 | _index?: number; 69 | } 70 | 71 | export type Record = RecordType & { 72 | group?: boolean; 73 | borderColor?: string; 74 | backgroundColor?: string; 75 | collapsed?: boolean; 76 | children?: Record[]; 77 | }; 78 | export interface Column { 79 | width?: number; 80 | minWidth?: number; 81 | maxWidth?: number; 82 | flex?: number; 83 | name: string; 84 | label: string; 85 | render?: (item: Record) => React.ReactNode; 86 | } 87 | export type DependenceType = 88 | | 'start_finish' 89 | | 'finish_start' 90 | | 'start_start' 91 | | 'finish_finish'; 92 | export interface Dependence { 93 | from: string; 94 | to: string; 95 | type: DependenceType; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Gantt } from './types'; 2 | 3 | /** 4 | * 将树形数据向下递归为一维数组 5 | * @param {*} arr 数据源 6 | */ 7 | export function flattenDeep( 8 | arr: Gantt.Item[] = [], 9 | depth = 0, 10 | parent: Gantt.Item | undefined = undefined 11 | ): Gantt.Item[] { 12 | let index = 0; 13 | return arr.reduce((flat: Gantt.Item[], item) => { 14 | item._depth = depth; 15 | item._parent = parent; 16 | item._index = index; 17 | index += 1; 18 | return [ 19 | ...flat, 20 | item, 21 | ...(item.children && !item.collapsed 22 | ? flattenDeep(item.children, depth + 1, item) 23 | : []), 24 | ]; 25 | }, []); 26 | } 27 | 28 | export function getMaxRange(bar: Gantt.Bar) { 29 | let minTranslateX = 0; 30 | let maxTranslateX = 0; 31 | const temp: Gantt.Bar[] = [bar]; 32 | 33 | while (temp.length > 0) { 34 | const current = temp.shift(); 35 | if (current) { 36 | const { translateX = 0, width = 0 } = current; 37 | if (minTranslateX === 0) { 38 | minTranslateX = translateX || 0; 39 | } 40 | if (translateX) { 41 | minTranslateX = Math.min(translateX, minTranslateX); 42 | maxTranslateX = Math.max(translateX + width, maxTranslateX); 43 | } 44 | if (current.task.children && current.task.children.length > 0) { 45 | current.task.children.forEach((t) => { 46 | if (t._bar) { 47 | temp.push(t._bar); 48 | } 49 | }); 50 | } 51 | } 52 | } 53 | 54 | return { 55 | translateX: minTranslateX, 56 | width: maxTranslateX - minTranslateX, 57 | }; 58 | } 59 | const genKey = (() => { 60 | let key = 0; 61 | return function () { 62 | return key++; 63 | }; 64 | })(); 65 | export function transverseData( 66 | data: Gantt.Record[] = [], 67 | startDateKey: string, 68 | endDateKey: string 69 | ) { 70 | const result: Gantt.Item[] = []; 71 | 72 | data.forEach((record) => { 73 | const item: Gantt.Item = { 74 | key: genKey(), 75 | record, 76 | // TODO content 77 | content: '', 78 | group: record.group, 79 | groupWidthSelf: record.groupWidthSelf, 80 | startDate: record[startDateKey] || '', 81 | endDate: record[endDateKey] || '', 82 | collapsed: record.collapsed || false, 83 | children: transverseData(record.children || [], startDateKey, endDateKey), 84 | }; 85 | result.push(item); 86 | }); 87 | return result; 88 | } 89 | -------------------------------------------------------------------------------- /stories/1-basic.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import GanttComponent from '../src'; 3 | import createData, { GanttRecord } from './utils/createData' 4 | 5 | export default { 6 | title: 'Basic', 7 | component: GanttComponent, 8 | argTypes: { 9 | rowHeight: { control: { type: 'range', min: 30, max: 100, step: 10 } }, 10 | tableIndent: { control: { type: 'range', min: 20, max: 100, step: 1 } }, 11 | unit: { 12 | control: { 13 | type: 'select', 14 | options: [ 15 | 'day', 16 | 'week', 17 | 'month', 18 | 'quarter', 19 | 'halfYear', 20 | ], 21 | } 22 | }, 23 | }, 24 | } 25 | 26 | const GanttStory = ({ data, ...args }) => ( 27 |
28 | 29 | data={createData(100)} 30 | columns={[{ 31 | name: 'name', 32 | label: '名称', 33 | flex: 2, 34 | minWidth: 200, 35 | }, { 36 | name: 'startDate', 37 | label: '开始时间', 38 | flex: 1, 39 | minWidth: 100, 40 | }, { 41 | name: 'endDate', 42 | label: '结束时间', 43 | flex: 1, 44 | minWidth: 100, 45 | }]} 46 | onUpdate={async (item, startDate, endDate) => { 47 | item.startDate = startDate; 48 | item.endDate = endDate; 49 | return true 50 | }} 51 | renderBarThumb={(record) => record.content} 52 | {...args} 53 | /> 54 |
55 | ) 56 | 57 | export const Basic = GanttStory.bind({}); 58 | 59 | Basic.args = { 60 | rowHeight: 30, 61 | tableIndent: 20, 62 | showBackToday: true, 63 | showUnitSwitch: true, 64 | tableCollapseAble: true, 65 | unit: 'day' 66 | } -------------------------------------------------------------------------------- /stories/2-latge-data.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Gantt from '../src'; 3 | import createData from './utils/createData' 4 | 5 | export default { 6 | title: 'Large data', 7 | component: Gantt, 8 | } 9 | 10 | const GanttStory = ({ data, ...args }) => ( 11 |
12 | { 31 | item.startDate = startDate; 32 | item.endDate = endDate; 33 | return true 34 | }} 35 | renderBarThumb={(record) => record.content} 36 | {...args} 37 | /> 38 |
39 | ) 40 | 41 | export const Basic = GanttStory.bind({}); 42 | 43 | Basic.args = { 44 | } -------------------------------------------------------------------------------- /stories/3-dependencies.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Gantt from '../src'; 3 | import createData from './utils/createData' 4 | 5 | export default { 6 | title: 'Dependencies', 7 | component: Gantt, 8 | } 9 | 10 | const GanttStory = ({ data, ...args }) => ( 11 |
12 | { 80 | item.startDate = startDate; 81 | item.endDate = endDate; 82 | return true 83 | }} 84 | {...args} 85 | /> 86 |
87 | ) 88 | 89 | export const Basic = GanttStory.bind({}); 90 | 91 | Basic.args = { 92 | } -------------------------------------------------------------------------------- /stories/utils/createData.ts: -------------------------------------------------------------------------------- 1 | import { GanttProps } from '../../src'; 2 | export interface GanttRecord { 3 | id: string 4 | name: string 5 | content: string 6 | startDate: string | null, 7 | endDate: string | null, 8 | } 9 | export default function createData(count: number): GanttProps['data'] { 10 | return Array(count).fill(0).map((_, i) => ({ 11 | id: i.toString(), 12 | name: `一个名称${i}`, 13 | content: '一个名称', 14 | startDate: null, 15 | endDate: null, 16 | collapsed: false, 17 | children: [{ 18 | id: `${i}-child`, 19 | startDate: null, 20 | endDate: null, 21 | name: '子级', 22 | content: '子级', 23 | collapsed: false, 24 | children: [] 25 | }] 26 | })) 27 | } -------------------------------------------------------------------------------- /test/blah.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import Gantt from '../src'; 4 | 5 | describe('Basic', () => { 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render( 9 | { 34 | return true; 35 | }} 36 | />, 37 | div 38 | ); 39 | ReactDOM.unmountComponentAtNode(div); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": [ 4 | "src", 5 | "types", 6 | "./typings.d.ts" 7 | ], 8 | "compilerOptions": { 9 | "module": "esnext", 10 | "target": "es2019", 11 | "lib": [ 12 | "dom", 13 | "esnext" 14 | ], 15 | "importHelpers": true, 16 | // output .d.ts declaration files for consumers 17 | "declaration": true, 18 | // output .js.map sourcemap files for consumers 19 | "sourceMap": true, 20 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 21 | "rootDir": "./src", 22 | // 设为false,因为有些泛型限制太大 23 | "strict": false, 24 | // linter checks for common issues 25 | "noImplicitReturns": true, 26 | "noFallthroughCasesInSwitch": true, 27 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 28 | "noUnusedLocals": false, 29 | "noUnusedParameters": true, 30 | // use Node's module resolution algorithm, instead of the legacy TS one 31 | "moduleResolution": "node", 32 | // transpile JSX to React.createElement 33 | "jsx": "react", 34 | // interop between ESM and CJS modules. Recommended by TS 35 | "esModuleInterop": true, 36 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 37 | "skipLibCheck": true, 38 | // error out if import and file system have a casing mismatch. Recommended by TS 39 | "forceConsistentCasingInFileNames": true, 40 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 41 | "noEmit": true, 42 | "experimentalDecorators": true, 43 | } 44 | } -------------------------------------------------------------------------------- /tsdx.config.js: -------------------------------------------------------------------------------- 1 | const postcss = require('rollup-plugin-postcss'); 2 | const url = require("postcss-url") 3 | module.exports = { 4 | rollup(config, options) { 5 | config.plugins.push( 6 | postcss({ 7 | minimize: true, 8 | use: { 9 | less: { javascriptEnabled: true } 10 | }, 11 | extract: true, 12 | plugins: [url({ 13 | url: 'inline' 14 | })] 15 | }) 16 | ); 17 | return config; 18 | }, 19 | }; -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less' { 3 | const resource: {[key: string]: string}; 4 | export = resource; 5 | } 6 | --------------------------------------------------------------------------------