├── .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 |
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 |
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 |
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 |
121 |
127 | {dateTextFormat(translateX)}
128 |
129 |
135 | {dateTextFormat(translateX + width)}
136 |
137 |
,
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 |
--------------------------------------------------------------------------------
/src/components/scroll-top/Top_hover.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
30 | ) : (
31 |
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 |
134 |
135 | )}
136 | {stepGesture !== 'moving' && (
137 |
138 |
144 |
145 | )} */}
146 |
166 |
186 |
193 | >
194 | )}
195 |
211 | {renderBar ? (
212 | renderBar(data, {
213 | width: width + 1,
214 | height: barHeight + 1,
215 | })
216 | ) : (
217 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------