v2.3.1
This commit is contained in:
7
src/components/ContextMenu.css
Normal file
7
src/components/ContextMenu.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.wx-menu .wx-option.wx-disabled.wx-LU2cdPQ2 {
|
||||
pointer-events: none;
|
||||
}
|
||||
.wx-menu .wx-option.wx-disabled.wx-LU2cdPQ2 .wx-value,
|
||||
.wx-menu .wx-option.wx-disabled.wx-LU2cdPQ2 .wx-icon {
|
||||
color: var(--wx-color-font-disabled);
|
||||
}
|
||||
179
src/components/ContextMenu.jsx
Normal file
179
src/components/ContextMenu.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { ContextMenu as WxContextMenu } from '@svar-ui/react-menu';
|
||||
import {
|
||||
handleAction,
|
||||
defaultMenuOptions,
|
||||
isHandledAction,
|
||||
} from '@svar-ui/gantt-store';
|
||||
import { locale } from '@svar-ui/lib-dom';
|
||||
import { en } from '@svar-ui/gantt-locales';
|
||||
import { en as coreEn } from '@svar-ui/core-locales';
|
||||
import { context } from '@svar-ui/react-core';
|
||||
import { useWritableProp, useStoreLater } from '@svar-ui/lib-react';
|
||||
import './ContextMenu.css';
|
||||
|
||||
const ContextMenu = forwardRef(function ContextMenu(
|
||||
{
|
||||
options: optionsInit,
|
||||
api = null,
|
||||
resolver = null,
|
||||
filter = null,
|
||||
at = 'point',
|
||||
children,
|
||||
onClick,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const ownMenu = useMemo(() => optionsInit ?? [...defaultMenuOptions], [optionsInit]);
|
||||
const [optionsProp] = useWritableProp(ownMenu);
|
||||
|
||||
const menuRef = useRef(null);
|
||||
const activeIdRef = useRef(null);
|
||||
|
||||
// i18n context
|
||||
const i18nCtx = useContext(context.i18n);
|
||||
const l = useMemo(() => i18nCtx || locale({ ...en, ...coreEn }), [i18nCtx]);
|
||||
const _ = useMemo(() => l.getGroup('gantt'), [l]);
|
||||
|
||||
const rTaskTypesVal = useStoreLater(api, "taskTypes");
|
||||
const rTasksVal = useStoreLater(api, "_tasks");
|
||||
const rSelectedVal = useStoreLater(api, "selected");
|
||||
const rSelectedTasksVal = useStoreLater(api, "_selected");
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
api.on('scroll-chart', () => {
|
||||
if (menuRef.current && menuRef.current.show) menuRef.current.show();
|
||||
});
|
||||
api.on('drag-task', () => {
|
||||
if (menuRef.current && menuRef.current.show) menuRef.current.show();
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
function applyLocaleFn(opts) {
|
||||
return opts.map((op) => {
|
||||
op = { ...op };
|
||||
if (op.text) op.text = _(op.text);
|
||||
if (op.subtext) op.subtext = _(op.subtext);
|
||||
if (op.data) op.data = applyLocaleFn(op.data);
|
||||
return op;
|
||||
});
|
||||
}
|
||||
|
||||
function getOptions() {
|
||||
const convertOption = optionsProp.find((o) => o.id === 'convert-task');
|
||||
if (convertOption) {
|
||||
convertOption.data = [];
|
||||
(rTaskTypesVal || []).forEach((t) => {
|
||||
convertOption.data.push(convertOption.dataFactory(t));
|
||||
});
|
||||
}
|
||||
return applyLocaleFn(optionsProp);
|
||||
}
|
||||
|
||||
const cOptions = useMemo(() => {
|
||||
if (api) {
|
||||
return getOptions();
|
||||
}
|
||||
return null;
|
||||
}, [api, optionsProp, rTaskTypesVal, _]);
|
||||
|
||||
const selectedTasks = useMemo(
|
||||
() =>
|
||||
rSelectedTasksVal && rSelectedTasksVal.length ? rSelectedTasksVal : [],
|
||||
[rSelectedTasksVal],
|
||||
);
|
||||
|
||||
const itemResolver = useCallback(
|
||||
(id, ev) => {
|
||||
let task = id ? api?.getTask(id) : null;
|
||||
if (resolver) {
|
||||
const result = resolver(id, ev);
|
||||
task = result === true ? task : result;
|
||||
}
|
||||
if (task) {
|
||||
activeIdRef.current = task.id;
|
||||
if (!Array.isArray(rSelectedVal) || !rSelectedVal.includes(task.id)) {
|
||||
api && api.exec && api.exec('select-task', { id: task.id });
|
||||
}
|
||||
}
|
||||
return task;
|
||||
},
|
||||
[api, resolver, rSelectedVal],
|
||||
);
|
||||
|
||||
const menuAction = useCallback(
|
||||
(ev) => {
|
||||
const action = ev.action;
|
||||
if (action) {
|
||||
const isAction = isHandledAction(defaultMenuOptions, action.id);
|
||||
if (isAction) handleAction(api, action.id, activeIdRef.current, _);
|
||||
onClick && onClick(ev);
|
||||
}
|
||||
},
|
||||
[api, _, onClick],
|
||||
);
|
||||
|
||||
const filterMenu = useCallback(
|
||||
(item, task) => {
|
||||
const tasks = selectedTasks.length ? selectedTasks : task ? [task] : [];
|
||||
|
||||
let result = filter ? filter(item, task) : true;
|
||||
if (item.check && result) {
|
||||
const isDisabled = tasks.some((t) => !item.check(t, rTasksVal));
|
||||
item.css = isDisabled ? 'wx-disabled' : '';
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[filter, selectedTasks, rTasksVal],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
show: (ev, obj) => {
|
||||
if (menuRef.current && menuRef.current.show) {
|
||||
menuRef.current.show(ev, obj);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const onContextMenu = useCallback((e) => {
|
||||
if (menuRef.current && menuRef.current.show) {
|
||||
menuRef.current.show(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<WxContextMenu
|
||||
filter={filterMenu}
|
||||
options={cOptions}
|
||||
dataKey={'id'}
|
||||
resolver={itemResolver}
|
||||
onClick={menuAction}
|
||||
at={at}
|
||||
ref={menuRef}
|
||||
/>
|
||||
<span onContextMenu={onContextMenu} data-menu-ignore="true">
|
||||
{typeof children === 'function' ? children() : children}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!i18nCtx && context.i18n?.Provider) {
|
||||
const Provider = context.i18n.Provider;
|
||||
return <Provider value={l}>{content}</Provider>;
|
||||
}
|
||||
|
||||
return content;
|
||||
});
|
||||
|
||||
export default ContextMenu;
|
||||
6
src/components/Editor.css
Normal file
6
src/components/Editor.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.wx-sidearea .wx-gantt-editor.wx-XkvqDXuw {
|
||||
width: 400px;
|
||||
}
|
||||
.wx-sidearea .wx-gantt-editor.wx-full-screen.wx-XkvqDXuw {
|
||||
width: 100%;
|
||||
}
|
||||
302
src/components/Editor.jsx
Normal file
302
src/components/Editor.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useContext } from 'react';
|
||||
import { Editor as WxEditor, registerEditorItem } from '@svar-ui/react-editor';
|
||||
import { Locale, RichSelect, Slider, Counter, TwoState } from '@svar-ui/react-core';
|
||||
import { defaultEditorItems, normalizeDates } from '@svar-ui/gantt-store';
|
||||
import { dateToString, locale } from '@svar-ui/lib-dom';
|
||||
import { en } from '@svar-ui/gantt-locales';
|
||||
import { en as coreEn } from '@svar-ui/core-locales';
|
||||
import { context } from '@svar-ui/react-core';
|
||||
|
||||
import Links from './editor/Links.jsx';
|
||||
import DateTimePicker from './editor/DateTimePicker.jsx';
|
||||
import { useStore, useWritableProp } from '@svar-ui/lib-react';
|
||||
|
||||
// helpers
|
||||
import { modeObserver } from '../helpers/modeResizeObserver';
|
||||
|
||||
import './Editor.css';
|
||||
|
||||
registerEditorItem('select', RichSelect);
|
||||
registerEditorItem('date', DateTimePicker);
|
||||
registerEditorItem('twostate', TwoState);
|
||||
registerEditorItem('slider', Slider);
|
||||
registerEditorItem('counter', Counter);
|
||||
registerEditorItem('links', Links);
|
||||
|
||||
function Editor({
|
||||
api,
|
||||
items = defaultEditorItems,
|
||||
css = '',
|
||||
layout = 'default',
|
||||
readonly = false,
|
||||
placement = 'sidebar',
|
||||
bottomBar = true,
|
||||
topBar = true,
|
||||
autoSave = true,
|
||||
focus = false,
|
||||
}) {
|
||||
const lFromCtx = useContext(context.i18n);
|
||||
const l = useMemo(() => lFromCtx || locale({ ...en, ...coreEn }), [lFromCtx]);
|
||||
const _ = useMemo(() => l.getGroup('gantt'), [l]);
|
||||
const i18nData = l.getRaw();
|
||||
const dateFormat = useMemo(() => {
|
||||
const f = i18nData.gantt?.dateFormat || i18nData.formats?.dateFormat;
|
||||
return dateToString(f, i18nData.calendar);
|
||||
}, [i18nData]);
|
||||
|
||||
const normalizedTopBar = useMemo(() => {
|
||||
if (topBar === true && !readonly) {
|
||||
const buttons = [
|
||||
{ comp: 'icon', icon: 'wxi-close', id: 'close' },
|
||||
{ comp: 'spacer' },
|
||||
{
|
||||
comp: 'button',
|
||||
type: 'danger',
|
||||
text: _('Delete'),
|
||||
id: 'delete',
|
||||
},
|
||||
];
|
||||
if (autoSave) return { items: buttons };
|
||||
return {
|
||||
items: [
|
||||
...buttons,
|
||||
{
|
||||
comp: 'button',
|
||||
type: 'primary',
|
||||
text: _('Save'),
|
||||
id: 'save',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return topBar;
|
||||
}, [topBar, readonly, autoSave, _]);
|
||||
|
||||
// resize
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const styleCss = useMemo(
|
||||
() => (compactMode ? 'wx-full-screen' : ''),
|
||||
[compactMode],
|
||||
);
|
||||
|
||||
const handleResize = useCallback((mode) => {
|
||||
setCompactMode(mode);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ro = modeObserver(handleResize);
|
||||
ro.observe();
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
const activeTask = useStore(api, "_activeTask");
|
||||
// const taskId = useStore(api, "activeTaskId");
|
||||
const taskId = useMemo(() => activeTask?.id, [activeTask]);
|
||||
const unit = useStore(api, "durationUnit");
|
||||
const unscheduledTasks = useStore(api, "unscheduledTasks");
|
||||
const taskTypes = useStore(api, "taskTypes");
|
||||
|
||||
const [taskType, setTaskType] = useWritableProp(activeTask?.type);
|
||||
const taskUnscheduled = useMemo(() => activeTask?.unscheduled, [activeTask]);
|
||||
const [linksActionsMap, setLinksActionsMap] = useState({});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setLinksActionsMap({});
|
||||
}, [taskId]);
|
||||
|
||||
const milestone = useMemo(() => taskType === 'milestone', [taskType]);
|
||||
const summary = useMemo(() => taskType === 'summary', [taskType]);
|
||||
|
||||
function prepareEditorItems(localItems, isUnscheduled) {
|
||||
const dates = { start: 1, end: 1, duration: 1 };
|
||||
|
||||
return localItems.map((a) => {
|
||||
const item = { ...a };
|
||||
if (a.config) item.config = { ...item.config };
|
||||
if (item.comp === 'links' && api) {
|
||||
item.api = api;
|
||||
item.autoSave = autoSave;
|
||||
item.onLinksChange = handleLinksChange;
|
||||
}
|
||||
if (item.comp === 'select' && item.key === 'type') {
|
||||
let options = item.options ?? (taskTypes ? taskTypes : []);
|
||||
item.options = options.map((t) => ({
|
||||
...t,
|
||||
label: _(t.label),
|
||||
}));
|
||||
}
|
||||
|
||||
if (item.comp === 'slider' && item.key === 'progress') {
|
||||
item.labelTemplate = (value) => `${_(item.label)} ${value}%`;
|
||||
}
|
||||
|
||||
if (item.label) item.label = _(item.label);
|
||||
if (item.config?.placeholder)
|
||||
item.config.placeholder = _(item.config.placeholder);
|
||||
|
||||
if (unscheduledTasks && dates[item.key]) {
|
||||
if (isUnscheduled) {
|
||||
item.disabled = true;
|
||||
} else {
|
||||
delete item.disabled;
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function filterEditorItems(localItems) {
|
||||
return localItems.filter(({ comp, key, options }) => {
|
||||
switch (comp) {
|
||||
case 'date': {
|
||||
return (
|
||||
(!milestone || (key !== 'end' && key !== 'base_end')) && !summary
|
||||
);
|
||||
}
|
||||
case 'select': {
|
||||
return options.length > 1;
|
||||
}
|
||||
case 'twostate': {
|
||||
return unscheduledTasks && !summary;
|
||||
}
|
||||
case 'counter': {
|
||||
return !summary && !milestone;
|
||||
}
|
||||
case 'slider': {
|
||||
return !milestone;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const editorItems = useMemo(() => {
|
||||
const eItems = prepareEditorItems(items, taskUnscheduled);
|
||||
return filterEditorItems(eItems);
|
||||
}, [
|
||||
items,
|
||||
taskUnscheduled,
|
||||
milestone,
|
||||
summary,
|
||||
unscheduledTasks,
|
||||
taskTypes,
|
||||
_,
|
||||
api,
|
||||
autoSave,
|
||||
]);
|
||||
|
||||
const task = useMemo(() => {
|
||||
if (readonly && activeTask) {
|
||||
let values = {};
|
||||
editorItems.forEach(({ key, comp }) => {
|
||||
if (comp !== 'links') {
|
||||
const value = activeTask[key];
|
||||
if (comp === 'date' && value instanceof Date) {
|
||||
values[key] = dateFormat(value);
|
||||
} else if (comp === 'slider' && key === 'progress') {
|
||||
values[key] = `${value}%`;
|
||||
} else {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
return activeTask ? { ...activeTask } : null;
|
||||
}, [readonly, activeTask, editorItems, dateFormat]);
|
||||
|
||||
function handleLinksChange({ id, action, data }) {
|
||||
setLinksActionsMap((prev) => ({
|
||||
...prev,
|
||||
[id]: { action, data },
|
||||
}));
|
||||
}
|
||||
|
||||
const saveLinks = useCallback(() => {
|
||||
for (let link in linksActionsMap) {
|
||||
const { action, data } = linksActionsMap[link];
|
||||
api.exec(action, data);
|
||||
}
|
||||
}, [api, linksActionsMap]);
|
||||
|
||||
const deleteTask = useCallback(() => {
|
||||
api.exec('delete-task', { id: taskId });
|
||||
}, [api, taskId]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
api.exec('show-editor', { id: null });
|
||||
}, [api]);
|
||||
|
||||
const handleAction = useCallback((ev) => {
|
||||
const { item, changes } = ev;
|
||||
if (item.id === 'delete') {
|
||||
deleteTask();
|
||||
}
|
||||
if (item.id === 'save') {
|
||||
if (!changes.length) saveLinks();
|
||||
else hide();
|
||||
}
|
||||
if (item.comp) hide();
|
||||
}, [api, taskId, autoSave, saveLinks, deleteTask, hide]);
|
||||
|
||||
const normalizeTask = useCallback((t, key) => {
|
||||
if (unscheduledTasks && t.type === 'summary') t.unscheduled = false;
|
||||
|
||||
normalizeDates(t, unit, true, key);
|
||||
return t;
|
||||
}, [unscheduledTasks, unit]);
|
||||
|
||||
const handleChange = useCallback((ev) => {
|
||||
let { update, key, value } = ev;
|
||||
ev.update = normalizeTask({ ...update }, key);
|
||||
|
||||
if (!autoSave) {
|
||||
if (key === 'type') setTaskType(value);
|
||||
}
|
||||
}, [api, autoSave]);
|
||||
|
||||
const handleSave = useCallback((ev) => {
|
||||
let { values } = ev;
|
||||
values = {
|
||||
...values,
|
||||
unscheduled:
|
||||
unscheduledTasks && values.unscheduled && values.type !== 'summary',
|
||||
};
|
||||
delete values.links;
|
||||
delete values.data;
|
||||
|
||||
api.exec('update-task', {
|
||||
id: taskId,
|
||||
task: values,
|
||||
});
|
||||
|
||||
if (!autoSave) saveLinks();
|
||||
}, [api, taskId, unscheduledTasks, autoSave, saveLinks]);
|
||||
|
||||
return task ? (
|
||||
<Locale>
|
||||
<WxEditor
|
||||
css={`wx-XkvqDXuw wx-gantt-editor ${styleCss} ${css}`}
|
||||
items={editorItems}
|
||||
values={task}
|
||||
topBar={normalizedTopBar}
|
||||
bottomBar={bottomBar}
|
||||
placement={placement}
|
||||
layout={layout}
|
||||
readonly={readonly}
|
||||
autoSave={autoSave}
|
||||
focus={focus}
|
||||
onAction={handleAction}
|
||||
onSave={handleSave}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Locale>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default Editor;
|
||||
12
src/components/Fullscreen.css
Normal file
12
src/components/Fullscreen.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.wx-fullscreen.wx-KG2RkQhB {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.wx-fullscreen-icon.wx-KG2RkQhB {
|
||||
position: absolute;
|
||||
right: 25px;
|
||||
bottom: 35px;
|
||||
z-index: 4;
|
||||
}
|
||||
55
src/components/Fullscreen.jsx
Normal file
55
src/components/Fullscreen.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import IconButton from '../widgets/IconButton.jsx';
|
||||
import { hotkeys } from '../helpers/hotkey';
|
||||
import './Fullscreen.css';
|
||||
|
||||
function Fullscreen({ hotkey = null, children }) {
|
||||
const nodeRef = useRef(null);
|
||||
const [inFullscreen, setInFullscreen] = useState(false);
|
||||
const inFullscreenRef = useRef(inFullscreen);
|
||||
|
||||
useEffect(() => {
|
||||
inFullscreenRef.current = inFullscreen;
|
||||
}, [inFullscreen]);
|
||||
|
||||
const toggleFullscreen = useRef(() => {
|
||||
const node = nodeRef.current;
|
||||
const mode = !inFullscreenRef.current;
|
||||
|
||||
if (mode && node) {
|
||||
node.requestFullscreen();
|
||||
} else if (inFullscreenRef.current) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
setInFullscreen(mode);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
hotkeys.subscribe(v => v.add(hotkey, toggleFullscreen.current));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const setFullscreenState = () => {
|
||||
setInFullscreen(document.fullscreenElement === nodeRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', setFullscreenState);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', setFullscreenState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div tabIndex={0} className="wx-KG2RkQhB wx-fullscreen" ref={nodeRef}>
|
||||
{children}
|
||||
<div className="wx-KG2RkQhB wx-fullscreen-icon">
|
||||
<IconButton
|
||||
appearance={'transparent'}
|
||||
icon={`wxi-${inFullscreen ? 'collapse' : 'expand'}`}
|
||||
onClick={() => toggleFullscreen.current()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Fullscreen;
|
||||
219
src/components/Gantt.jsx
Normal file
219
src/components/Gantt.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
// core widgets lib
|
||||
import { Locale } from '@svar-ui/react-core';
|
||||
import { en } from '@svar-ui/gantt-locales';
|
||||
|
||||
// stores
|
||||
import { EventBusRouter } from '@svar-ui/lib-state';
|
||||
import { DataStore, defaultColumns, defaultTaskTypes } from '@svar-ui/gantt-store';
|
||||
|
||||
// context
|
||||
import StoreContext from '../context';
|
||||
|
||||
// store factory
|
||||
import { writable } from '@svar-ui/lib-react';
|
||||
|
||||
// ui
|
||||
import Layout from './Layout.jsx';
|
||||
|
||||
|
||||
const camelize = (s) =>
|
||||
s
|
||||
.split('-')
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : ''))
|
||||
.join('');
|
||||
|
||||
const defaultScales = [
|
||||
{ unit: 'month', step: 1, format: 'MMMM yyy' },
|
||||
{ unit: 'day', step: 1, format: 'd' },
|
||||
];
|
||||
|
||||
const Gantt = forwardRef(function Gantt(
|
||||
{
|
||||
taskTemplate = null,
|
||||
markers = [],
|
||||
taskTypes = defaultTaskTypes,
|
||||
tasks = [],
|
||||
selected = [],
|
||||
activeTask = null,
|
||||
links = [],
|
||||
scales = defaultScales,
|
||||
columns = defaultColumns,
|
||||
start = null,
|
||||
end = null,
|
||||
lengthUnit = 'day',
|
||||
durationUnit = 'day',
|
||||
cellWidth = 100,
|
||||
cellHeight = 38,
|
||||
scaleHeight = 36,
|
||||
readonly = false,
|
||||
cellBorders = 'full',
|
||||
zoom = false,
|
||||
baselines = false,
|
||||
highlightTime = null,
|
||||
init = null,
|
||||
autoScale = true,
|
||||
unscheduledTasks = false,
|
||||
...restProps
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// keep latest rest props for event routing
|
||||
const restPropsRef = useRef();
|
||||
restPropsRef.current = restProps;
|
||||
|
||||
|
||||
// init stores
|
||||
const dataStore = useMemo(() => new DataStore(writable), []);
|
||||
const firstInRoute = useMemo(() => dataStore.in, [dataStore]);
|
||||
|
||||
const lastInRouteRef = useRef(null);
|
||||
if (lastInRouteRef.current === null) {
|
||||
lastInRouteRef.current = new EventBusRouter((a, b) => {
|
||||
const name = 'on' + camelize(a);
|
||||
if (restPropsRef.current && restPropsRef.current[name]) {
|
||||
restPropsRef.current[name](b);
|
||||
}
|
||||
});
|
||||
firstInRoute.setNext(lastInRouteRef.current);
|
||||
}
|
||||
|
||||
|
||||
// writable prop for two-way binding tableAPI
|
||||
const [tableAPI, setTableAPI] = useState(null);
|
||||
const tableAPIRef = useRef(null);
|
||||
tableAPIRef.current = tableAPI;
|
||||
|
||||
// public API
|
||||
const api = useMemo(
|
||||
() => ({
|
||||
getState: dataStore.getState.bind(dataStore),
|
||||
getReactiveState: dataStore.getReactive.bind(dataStore),
|
||||
getStores: () => ({ data: dataStore }),
|
||||
exec: firstInRoute.exec,
|
||||
setNext: (ev) => {
|
||||
lastInRouteRef.current = lastInRouteRef.current.setNext(ev);
|
||||
return lastInRouteRef.current;
|
||||
},
|
||||
intercept: firstInRoute.intercept.bind(firstInRoute),
|
||||
on: firstInRoute.on.bind(firstInRoute),
|
||||
detach: firstInRoute.detach.bind(firstInRoute),
|
||||
getTask: dataStore.getTask.bind(dataStore),
|
||||
serialize: dataStore.serialize.bind(dataStore),
|
||||
getTable: (waitRender) =>
|
||||
waitRender
|
||||
? new Promise((res) => setTimeout(() => res(tableAPIRef.current), 1))
|
||||
: tableAPIRef.current,
|
||||
}),
|
||||
[dataStore, firstInRoute],
|
||||
);
|
||||
|
||||
|
||||
// expose API via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
...api,
|
||||
}),
|
||||
[api],
|
||||
);
|
||||
|
||||
const initOnceRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!initOnceRef.current) {
|
||||
if (init) init(api);
|
||||
} else {
|
||||
// const prev = dataStore.getState();
|
||||
dataStore.init({
|
||||
tasks,
|
||||
links,
|
||||
start,
|
||||
columns,
|
||||
end,
|
||||
lengthUnit,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
scaleHeight,
|
||||
scales,
|
||||
taskTypes,
|
||||
zoom,
|
||||
selected,
|
||||
activeTask,
|
||||
baselines,
|
||||
autoScale,
|
||||
unscheduledTasks,
|
||||
markers,
|
||||
durationUnit,
|
||||
});
|
||||
}
|
||||
initOnceRef.current++;
|
||||
}, [
|
||||
tasks,
|
||||
links,
|
||||
start,
|
||||
columns,
|
||||
end,
|
||||
lengthUnit,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
scaleHeight,
|
||||
scales,
|
||||
taskTypes,
|
||||
zoom,
|
||||
selected,
|
||||
activeTask,
|
||||
baselines,
|
||||
autoScale,
|
||||
unscheduledTasks,
|
||||
markers,
|
||||
durationUnit,
|
||||
]);
|
||||
|
||||
if (initOnceRef.current === 0) {
|
||||
dataStore.init({
|
||||
tasks,
|
||||
links,
|
||||
start,
|
||||
columns,
|
||||
end,
|
||||
lengthUnit,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
scaleHeight,
|
||||
scales,
|
||||
taskTypes,
|
||||
zoom,
|
||||
selected,
|
||||
activeTask,
|
||||
baselines,
|
||||
autoScale,
|
||||
unscheduledTasks,
|
||||
markers,
|
||||
durationUnit,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Locale words={en} optional={true}>
|
||||
<StoreContext.Provider value={api}>
|
||||
<Layout
|
||||
taskTemplate={taskTemplate}
|
||||
readonly={readonly}
|
||||
cellBorders={cellBorders}
|
||||
highlightTime={highlightTime}
|
||||
onTableAPIChange={setTableAPI}
|
||||
/>
|
||||
</StoreContext.Provider>
|
||||
</Locale>
|
||||
);
|
||||
});
|
||||
|
||||
export default Gantt;
|
||||
34
src/components/Layout.css
Normal file
34
src/components/Layout.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.wx-gantt.wx-jlbQoHOz {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wx-pseudo-rows.wx-jlbQoHOz {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
.wx-stuck.wx-jlbQoHOz {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.wx-layout.wx-jlbQoHOz {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
background-color: var(--wx-background);
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wx-content.wx-jlbQoHOz {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
310
src/components/Layout.jsx
Normal file
310
src/components/Layout.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { hotkeys } from '@svar-ui/grid-store';
|
||||
import TimeScales from './TimeScale.jsx';
|
||||
import Grid from './grid/Grid.jsx';
|
||||
import Chart from './chart/Chart.jsx';
|
||||
import Resizer from './Resizer.jsx';
|
||||
import { modeObserver } from '../helpers/modeResizeObserver';
|
||||
import storeContext from '../context';
|
||||
import { useStore } from '@svar-ui/lib-react';
|
||||
import './Layout.css';
|
||||
|
||||
function Layout(props) {
|
||||
const {
|
||||
taskTemplate,
|
||||
readonly,
|
||||
cellBorders,
|
||||
highlightTime,
|
||||
onTableAPIChange
|
||||
} = props;
|
||||
|
||||
const api = useContext(storeContext);
|
||||
|
||||
const rTasks = useStore(api, "_tasks");
|
||||
const rScales = useStore(api, "_scales");
|
||||
const rCellHeight = useStore(api, "cellHeight");
|
||||
const rColumns = useStore(api, "columns");
|
||||
const scrollTop = useStore(api, "scrollTop");
|
||||
const rScrollTask = useStore(api, "_scrollTask");
|
||||
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
let [gridWidth, setGridWidth] = useState(0);
|
||||
const [ganttWidth, setGanttWidth] = useState(undefined);
|
||||
const [ganttHeight, setGanttHeight] = useState(undefined);
|
||||
const [innerWidth, setInnerWidth] = useState(undefined);
|
||||
const [display, setDisplay] = useState('all');
|
||||
|
||||
const lastDisplay = useRef(null);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(mode) => {
|
||||
setCompactMode((prev) => {
|
||||
if (mode !== prev) {
|
||||
if (mode) {
|
||||
lastDisplay.current = display;
|
||||
if (display === 'all') setDisplay('grid');
|
||||
} else if (!lastDisplay.current || lastDisplay.current === 'all') {
|
||||
setDisplay('all');
|
||||
}
|
||||
}
|
||||
return mode;
|
||||
});
|
||||
},
|
||||
[display],
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const ro = modeObserver(handleResize);
|
||||
ro.observe();
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
const gridColumnWidth = useMemo(() => {
|
||||
let w;
|
||||
if (rColumns.every((c) => c.width && !c.flexgrow)) {
|
||||
w = rColumns.reduce((acc, c) => acc + parseInt(c.width), 0);
|
||||
} else {
|
||||
if (compactMode && display === 'chart') {
|
||||
w = parseInt(rColumns.find((c) => c.id === 'action')?.width) || 50;
|
||||
} else {
|
||||
w = 440;
|
||||
}
|
||||
}
|
||||
gridWidth = w;
|
||||
return w;
|
||||
}, [rColumns, compactMode, display]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setGridWidth(gridColumnWidth);
|
||||
}, [gridColumnWidth]);
|
||||
|
||||
const scrollSize = useMemo(
|
||||
() => (ganttWidth ?? 0) - (innerWidth ?? 0),
|
||||
[ganttWidth, innerWidth],
|
||||
);
|
||||
const fullWidth = useMemo(() => rScales.width, [rScales]);
|
||||
const fullHeight = useMemo(
|
||||
() => rTasks.length * rCellHeight,
|
||||
[rTasks, rCellHeight],
|
||||
);
|
||||
const scrollHeight = useMemo(
|
||||
() => rScales.height + fullHeight + scrollSize,
|
||||
[rScales, fullHeight, scrollSize],
|
||||
);
|
||||
const totalWidth = useMemo(
|
||||
() => gridWidth + fullWidth,
|
||||
[gridWidth, fullWidth],
|
||||
);
|
||||
|
||||
const chartRef = useRef(null);
|
||||
const expandScale = useCallback(() => {
|
||||
Promise.resolve().then(() => {
|
||||
if ((ganttWidth ?? 0) > (totalWidth ?? 0)) {
|
||||
const minWidth = (ganttWidth ?? 0) - gridWidth;
|
||||
api.exec('expand-scale', { minWidth });
|
||||
}
|
||||
});
|
||||
}, [ganttWidth, totalWidth, gridWidth, api]);
|
||||
|
||||
useEffect(() => {
|
||||
let ro;
|
||||
if (chartRef.current) {
|
||||
ro = new ResizeObserver(expandScale);
|
||||
ro.observe(chartRef.current);
|
||||
}
|
||||
return () => {
|
||||
if (ro) ro.disconnect();
|
||||
};
|
||||
}, [chartRef.current, expandScale]);
|
||||
|
||||
const ganttDivRef = useRef(null);
|
||||
const pseudoRowsRef = useRef(null);
|
||||
|
||||
const syncScroll = useCallback(() => {
|
||||
const el = ganttDivRef.current;
|
||||
if (el && scrollTop !== el.scrollTop) el.scrollTop = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
syncScroll();
|
||||
}, [scrollTop, syncScroll]);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
const el = ganttDivRef.current;
|
||||
api.exec('scroll-chart', {
|
||||
top: el ? el.scrollTop : 0,
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const latest = useRef({
|
||||
rTasks: [],
|
||||
rScales: { height: 0 },
|
||||
rCellHeight: 0,
|
||||
scrollSize: 0,
|
||||
ganttDiv: null,
|
||||
ganttHeight: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latest.current = {
|
||||
rTasks,
|
||||
rScales,
|
||||
rCellHeight,
|
||||
scrollSize,
|
||||
ganttDiv: ganttDivRef.current,
|
||||
ganttHeight: ganttHeight ?? 0,
|
||||
};
|
||||
}, [rTasks, rScales, rCellHeight, scrollSize, ganttHeight]);
|
||||
|
||||
const scrollToTask = useCallback(
|
||||
(value) => {
|
||||
if (!value) return;
|
||||
const {
|
||||
rTasks: t,
|
||||
rScales: sc,
|
||||
rCellHeight: ch,
|
||||
scrollSize: ss,
|
||||
ganttDiv: el,
|
||||
ganttHeight: gh,
|
||||
} = latest.current;
|
||||
if (!el) return;
|
||||
const { id } = value;
|
||||
const index = t.findIndex((tt) => tt.id === id);
|
||||
if (index > -1) {
|
||||
const height = gh - sc.height;
|
||||
const scrollY = index * ch;
|
||||
const now = el.scrollTop;
|
||||
let top = null;
|
||||
if (scrollY < now) {
|
||||
top = scrollY;
|
||||
} else if (scrollY + ch > now + height) {
|
||||
top = scrollY - height + ch + ss;
|
||||
}
|
||||
if (top !== null) {
|
||||
api.exec('scroll-chart', { top: Math.max(top, 0) });
|
||||
}
|
||||
}
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToTask(rScrollTask);
|
||||
}, [rScrollTask]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ganttDivRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
setGanttHeight(el.offsetHeight);
|
||||
setGanttWidth(el.offsetWidth);
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [ganttDivRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = pseudoRowsRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
setInnerWidth(el.offsetWidth);
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [pseudoRowsRef.current]);
|
||||
|
||||
const layoutRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const node = layoutRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const cleanup = hotkeys(node, {
|
||||
keys: {
|
||||
'ctrl+c': true,
|
||||
'ctrl+v': true,
|
||||
'ctrl+x': true,
|
||||
'ctrl+d': true,
|
||||
backspace: true,
|
||||
},
|
||||
exec: (ev) => {
|
||||
if (!ev.isInput) api.exec('hotkey', ev);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return cleanup.destroy;
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div className="wx-jlbQoHOz wx-gantt" ref={ganttDivRef} onScroll={onScroll}>
|
||||
<div
|
||||
className="wx-jlbQoHOz wx-pseudo-rows"
|
||||
style={{ height: scrollHeight, width: '100%' }}
|
||||
ref={pseudoRowsRef}
|
||||
>
|
||||
<div
|
||||
className="wx-jlbQoHOz wx-stuck"
|
||||
style={{
|
||||
height: ganttHeight,
|
||||
width: innerWidth,
|
||||
}}
|
||||
>
|
||||
<div tabIndex={0} className="wx-jlbQoHOz wx-layout" ref={layoutRef}>
|
||||
{rColumns.length ? (
|
||||
<>
|
||||
<Grid
|
||||
display={display}
|
||||
compactMode={compactMode}
|
||||
columnWidth={gridColumnWidth}
|
||||
width={gridWidth}
|
||||
readonly={readonly}
|
||||
fullHeight={fullHeight}
|
||||
onTableAPIChange={onTableAPIChange}
|
||||
/>
|
||||
<Resizer
|
||||
value={gridWidth}
|
||||
display={display}
|
||||
compactMode={compactMode}
|
||||
minValue="50"
|
||||
maxValue="800"
|
||||
onMove={(value) => setGridWidth(value)}
|
||||
onDisplayChange={(display) => setDisplay(display)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="wx-jlbQoHOz wx-content" ref={chartRef}>
|
||||
<TimeScales highlightTime={highlightTime} />
|
||||
|
||||
<Chart
|
||||
readonly={readonly}
|
||||
fullWidth={fullWidth}
|
||||
fullHeight={fullHeight}
|
||||
taskTemplate={taskTemplate}
|
||||
cellBorders={cellBorders}
|
||||
highlightTime={highlightTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
137
src/components/Resizer.css
Normal file
137
src/components/Resizer.css
Normal file
@@ -0,0 +1,137 @@
|
||||
.wx-resizer.wx-resizer-display-all.wx-pFykzMlT:hover::before,
|
||||
.wx-resizer.wx-resizer-display-all.wx-pFykzMlT:hover::after,
|
||||
.wx-button-expand-content.wx-pFykzMlT::before,
|
||||
.wx-button-expand-content.wx-pFykzMlT::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: var(--wx-gantt-border-color);
|
||||
}
|
||||
|
||||
.wx-resizer.wx-pFykzMlT {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--wx-gantt-border-color);
|
||||
}
|
||||
.wx-resizer.wx-pFykzMlT:hover .wx-button-expand-content.wx-pFykzMlT {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wx-resizer.wx-resizer-display-all.wx-pFykzMlT:hover::before,
|
||||
.wx-resizer.wx-resizer-display-all.wx-pFykzMlT:hover::after {
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wx-resizer.wx-resizer-display-all.wx-pFykzMlT:hover::before {
|
||||
left: -3px;
|
||||
}
|
||||
|
||||
.wx-resizer.wx-resizer-display-all.wx-pFykzMlT:hover::after {
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
.wx-resizer-display-chart.wx-pFykzMlT .wx-button-expand-left.wx-pFykzMlT {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wx-resizer-display-grid.wx-pFykzMlT .wx-button-expand-right.wx-pFykzMlT {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wx-resizer-display-all.wx-pFykzMlT {
|
||||
.wx-button-expand-content.wx-pFykzMlT {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wx-resizer-display-all.wx-pFykzMlT .wx-button-expand-box.wx-pFykzMlT,
|
||||
.wx-resizer-display-chart.wx-pFykzMlT .wx-button-expand-box.wx-pFykzMlT {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.wx-resizer-display-grid.wx-pFykzMlT .wx-button-expand-left.wx-pFykzMlT {
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
.wx-resizer-display-chart.wx-pFykzMlT .wx-button-expand-left.wx-pFykzMlT,
|
||||
.wx-resizer-display-all.wx-pFykzMlT .wx-button-expand-left.wx-pFykzMlT {
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.wx-button-expand-box.wx-pFykzMlT {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.wx-button-expand-content.wx-pFykzMlT {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
|
||||
i.wx-pFykzMlT {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--wx-gantt-border-color);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
i.wx-pFykzMlT:hover {
|
||||
color: var(--wx-color-primary);
|
||||
}
|
||||
|
||||
i.wx-pFykzMlT:active {
|
||||
color: var(--wx-gantt-task-fill-color);
|
||||
}
|
||||
}
|
||||
|
||||
.wx-button-expand-right.wx-pFykzMlT {
|
||||
top: 4px;
|
||||
left: 1px;
|
||||
|
||||
&::before {
|
||||
top: -3.6px;
|
||||
width: 17px;
|
||||
height: 4px;
|
||||
clip-path: polygon(100% 100%, 0 0, 0 100%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 17px;
|
||||
height: 4px;
|
||||
clip-path: polygon(100% 0, 0 100%, 0 0);
|
||||
}
|
||||
|
||||
i.wx-pFykzMlT {
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.wx-button-expand-left.wx-pFykzMlT {
|
||||
top: 4px;
|
||||
i.wx-pFykzMlT {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: -3.6px;
|
||||
left: 3px;
|
||||
width: 17px;
|
||||
height: 4px;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
left: 3px;
|
||||
width: 17px;
|
||||
height: 4px;
|
||||
clip-path: polygon(0 0, 100% 100%, 100% 0);
|
||||
}
|
||||
}
|
||||
148
src/components/Resizer.jsx
Normal file
148
src/components/Resizer.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useWritableProp } from '@svar-ui/lib-react';
|
||||
import './Resizer.css';
|
||||
|
||||
function Resizer(props) {
|
||||
const {
|
||||
position = 'after',
|
||||
size = 4,
|
||||
dir = 'x',
|
||||
minValue = 0,
|
||||
maxValue = 0,
|
||||
onMove,
|
||||
onDisplayChange,
|
||||
compactMode,
|
||||
} = props;
|
||||
|
||||
const [value, setValue] = useWritableProp(props.value ?? 0);
|
||||
const [display, setDisplay] = useWritableProp(props.display ?? 'all');
|
||||
|
||||
function getBox(val) {
|
||||
let offset = 0;
|
||||
if (position == 'center') offset = size / 2;
|
||||
else if (position == 'before') offset = size;
|
||||
|
||||
const box = {
|
||||
size: [size + 'px', 'auto'],
|
||||
p: [val - offset + 'px', '0px'],
|
||||
p2: ['auto', '0px'],
|
||||
};
|
||||
|
||||
if (dir != 'x') {
|
||||
for (let name in box) box[name] = box[name].reverse();
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
const startRef = useRef(0);
|
||||
const posRef = useRef();
|
||||
|
||||
function getEventPos(ev) {
|
||||
return dir == 'x' ? ev.clientX : ev.clientY;
|
||||
}
|
||||
|
||||
const move = useCallback((ev) => {
|
||||
const newPos = posRef.current + getEventPos(ev) - startRef.current;
|
||||
if (
|
||||
(!minValue || minValue <= newPos) &&
|
||||
(!maxValue || maxValue >= newPos)
|
||||
) {
|
||||
setValue(newPos);
|
||||
onMove(newPos)
|
||||
}
|
||||
}, []);
|
||||
|
||||
const up = useCallback(() => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setActive(false);
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
}, [move]);
|
||||
|
||||
const cursor = useMemo(
|
||||
() => (display !== 'all' ? 'auto' : dir == 'x' ? 'ew-resize' : 'ns-resize'),
|
||||
[display, dir],
|
||||
);
|
||||
|
||||
const down = useCallback(
|
||||
(ev) => {
|
||||
startRef.current = getEventPos(ev);
|
||||
|
||||
posRef.current = value;
|
||||
setActive(true);
|
||||
|
||||
document.body.style.cursor = cursor;
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
},
|
||||
[cursor, move, up, value],
|
||||
);
|
||||
|
||||
function handleExpandLeft() {
|
||||
let newDisplay;
|
||||
if (compactMode) {
|
||||
newDisplay = display === 'chart' ? 'grid' : 'chart';
|
||||
} else {
|
||||
newDisplay = display === 'all' ? 'chart' : 'all';
|
||||
}
|
||||
setDisplay(newDisplay);
|
||||
onDisplayChange(newDisplay);
|
||||
}
|
||||
|
||||
function handleExpandRight() {
|
||||
let newDisplay;
|
||||
if (compactMode) {
|
||||
newDisplay = display === 'grid' ? 'chart' : 'grid';
|
||||
} else {
|
||||
newDisplay = display === 'all' ? 'grid' : 'all';
|
||||
}
|
||||
setDisplay(newDisplay);
|
||||
onDisplayChange(newDisplay);
|
||||
}
|
||||
|
||||
const b = useMemo(() => getBox(value), [value, position, size, dir]);
|
||||
|
||||
const rootClassName = [
|
||||
'wx-resizer',
|
||||
`wx-resizer-${dir}`,
|
||||
`wx-resizer-display-${display}`,
|
||||
active ? 'wx-resizer-active' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'wx-pFykzMlT ' + rootClassName}
|
||||
onMouseDown={down}
|
||||
style={{ width: b.size[0], height: b.size[1], cursor }}
|
||||
>
|
||||
<div className='wx-pFykzMlT wx-button-expand-box'>
|
||||
<div
|
||||
className='wx-pFykzMlT wx-button-expand-content wx-button-expand-left'
|
||||
>
|
||||
<i
|
||||
className='wx-pFykzMlT wxi-menu-left'
|
||||
onClick={handleExpandLeft}
|
||||
></i>
|
||||
</div>
|
||||
<div
|
||||
className='wx-pFykzMlT wx-button-expand-content wx-button-expand-right'
|
||||
>
|
||||
<i
|
||||
className='wx-pFykzMlT wxi-menu-right'
|
||||
onClick={handleExpandRight}
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
<div className='wx-pFykzMlT wx-resizer-line'></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Resizer;
|
||||
29
src/components/TimeScale.css
Normal file
29
src/components/TimeScale.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.wx-scale.wx-ZkvhDKir {
|
||||
position: relative;
|
||||
box-shadow: var(--wx-timescale-shadow);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wx-row.wx-ZkvhDKir,
|
||||
.wx-cell.wx-ZkvhDKir {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wx-row.wx-ZkvhDKir {
|
||||
border-bottom: var(--wx-gantt-border);
|
||||
}
|
||||
|
||||
.wx-cell.wx-ZkvhDKir {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-right: var(--wx-timescale-border);
|
||||
font: var(--wx-timescale-font);
|
||||
text-transform: var(--wx-timescale-text-transform);
|
||||
color: var(--wx-timescale-font-color);
|
||||
}
|
||||
|
||||
.wx-cell.wx-weekend.wx-ZkvhDKir {
|
||||
background: var(--wx-gantt-holiday-background);
|
||||
color: var(--wx-gantt-holiday-color);
|
||||
}
|
||||
49
src/components/TimeScale.jsx
Normal file
49
src/components/TimeScale.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useContext } from 'react';
|
||||
import storeContext from '../context';
|
||||
import { useStore } from '@svar-ui/lib-react';
|
||||
import './TimeScale.css';
|
||||
|
||||
function TimeScale(props) {
|
||||
const { highlightTime } = props;
|
||||
|
||||
const api = useContext(storeContext);
|
||||
const scales = useStore(api, "_scales");
|
||||
const scrollLeft = useStore(api, "scrollLeft");
|
||||
|
||||
const containerStyle = {
|
||||
width: `${(scales && scales.width) != null ? scales.width : 0}px`,
|
||||
left: `${-(scrollLeft != null ? scrollLeft : 0)}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wx-ZkvhDKir wx-scale" style={containerStyle}>
|
||||
{(scales?.rows || []).map((row, rowIdx) => (
|
||||
<div
|
||||
className="wx-ZkvhDKir wx-row"
|
||||
style={{ height: `${row.height}px` }}
|
||||
key={rowIdx}
|
||||
>
|
||||
{(row.cells || []).map((cell, cellIdx) => {
|
||||
const extraClass = highlightTime
|
||||
? highlightTime(cell.date, cell.unit)
|
||||
: '';
|
||||
const className = ['wx-cell', cell.css, extraClass]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<div
|
||||
className={'wx-ZkvhDKir ' + className}
|
||||
style={{ width: `${cell.width}px` }}
|
||||
key={cellIdx}
|
||||
>
|
||||
{cell.value}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeScale;
|
||||
60
src/components/Toolbar.jsx
Normal file
60
src/components/Toolbar.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { Toolbar as WxToolbar } from '@svar-ui/react-toolbar';
|
||||
import { useStoreLater } from '@svar-ui/lib-react';
|
||||
import {
|
||||
handleAction,
|
||||
defaultToolbarButtons,
|
||||
isHandledAction,
|
||||
} from '@svar-ui/gantt-store';
|
||||
import { locale } from '@svar-ui/lib-dom';
|
||||
import { en } from '@svar-ui/gantt-locales';
|
||||
import { context } from '@svar-ui/react-core';
|
||||
|
||||
export default function Toolbar({
|
||||
api = null,
|
||||
items = [...defaultToolbarButtons],
|
||||
}) {
|
||||
const i18nCtx = useContext(context.i18n);
|
||||
const i18nLocal = useMemo(() => (i18nCtx ? i18nCtx : locale(en)), [i18nCtx]);
|
||||
const _ = useMemo(() => i18nLocal.getGroup('gantt'), [i18nLocal]);
|
||||
|
||||
const rSelected = useStoreLater(api, "_selected");
|
||||
const rTasks = useStoreLater(api, "_tasks");
|
||||
|
||||
const finalItems = useMemo(() => {
|
||||
return items.map((b) => {
|
||||
let item = { ...b, disabled: false };
|
||||
item.handler = isHandledAction(defaultToolbarButtons, item.id)
|
||||
? (it) => handleAction(api, it.id, null, _)
|
||||
: item.handler;
|
||||
if (item.text) item.text = _(item.text);
|
||||
if (item.menuText) item.menuText = _(item.menuText);
|
||||
return item;
|
||||
});
|
||||
}, [items, api, _]);
|
||||
|
||||
const buttons = useMemo(() => {
|
||||
if (api) {
|
||||
if (rSelected?.length) {
|
||||
return finalItems.map((item) => {
|
||||
if (!item.check) return item;
|
||||
const isDisabled = rSelected.some(
|
||||
(task) => !item.check(task, rTasks),
|
||||
);
|
||||
return { ...item, disabled: isDisabled };
|
||||
});
|
||||
}
|
||||
}
|
||||
return [{ ...finalItems[0], disabled: false }];
|
||||
}, [api, rSelected, rTasks, finalItems]);
|
||||
|
||||
if (!i18nCtx) {
|
||||
return (
|
||||
<context.i18n.Provider value={i18nLocal}>
|
||||
<WxToolbar items={buttons} />
|
||||
</context.i18n.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return <WxToolbar items={buttons} />;
|
||||
}
|
||||
237
src/components/chart/Bars.css
Normal file
237
src/components/chart/Bars.css
Normal file
@@ -0,0 +1,237 @@
|
||||
.wx-baseline.wx-GKbcLEGA {
|
||||
position: absolute;
|
||||
background-color: #a883e4;
|
||||
border-radius: var(--wx-gantt-baseline-border-radius);
|
||||
z-index: 1;
|
||||
}
|
||||
.wx-baseline.wx-milestone.wx-GKbcLEGA {
|
||||
transform: rotate(45deg) scale(0.75);
|
||||
border-radius: var(--wx-gantt-milestone-border-radius);
|
||||
}
|
||||
.wx-bars.wx-GKbcLEGA {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wx-bar.wx-GKbcLEGA {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
border-radius: var(--wx-gantt-bar-border-radius);
|
||||
font: var(--wx-gantt-bar-font);
|
||||
white-space: nowrap;
|
||||
line-height: inherit;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.wx-bar.wx-touch.wx-GKbcLEGA {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.wx-bar.wx-reorder-task.wx-GKbcLEGA {
|
||||
z-index: 3;
|
||||
}
|
||||
.wx-content.wx-GKbcLEGA {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wx-task.wx-GKbcLEGA {
|
||||
color: var(--wx-gantt-task-font-color);
|
||||
background-color: var(--wx-gantt-task-color);
|
||||
border: var(--wx-gantt-task-border);
|
||||
}
|
||||
|
||||
.wx-task.wx-selected.wx-GKbcLEGA {
|
||||
border: 1px solid var(--wx-gantt-task-border-color);
|
||||
box-shadow: var(--wx-gantt-bar-shadow);
|
||||
}
|
||||
|
||||
.wx-task:hover.wx-GKbcLEGA {
|
||||
box-shadow: var(--wx-gantt-bar-shadow);
|
||||
}
|
||||
|
||||
.wx-summary.wx-GKbcLEGA {
|
||||
color: var(--wx-gantt-summary-font-color);
|
||||
background-color: var(--wx-gantt-summary-color);
|
||||
border: var(--wx-gantt-summary-border);
|
||||
}
|
||||
|
||||
.wx-summary.wx-selected.wx-GKbcLEGA {
|
||||
border: 1px solid var(--wx-gantt-summary-border-color);
|
||||
box-shadow: var(--wx-gantt-bar-shadow);
|
||||
}
|
||||
|
||||
.wx-summary:hover.wx-GKbcLEGA {
|
||||
box-shadow: var(--wx-gantt-bar-shadow);
|
||||
}
|
||||
|
||||
.wx-milestone .wx-content.wx-GKbcLEGA {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.wx-bar:not(.wx-milestone) .wx-content.wx-GKbcLEGA {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.wx-bar.wx-GKbcLEGA .wx-text-out {
|
||||
position: absolute;
|
||||
line-height: normal;
|
||||
display: block;
|
||||
color: var(--wx-color-font);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wx-milestone.wx-GKbcLEGA {
|
||||
border-color: var(--wx-gantt-milestone-color);
|
||||
}
|
||||
|
||||
.wx-milestone .wx-text-out.wx-GKbcLEGA {
|
||||
padding: 0 2px;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.wx-milestone .wx-content.wx-GKbcLEGA {
|
||||
height: 100%;
|
||||
background-color: var(--wx-gantt-milestone-color);
|
||||
transform: rotate(45deg) scale(0.75);
|
||||
border-radius: var(--wx-gantt-milestone-border-radius);
|
||||
}
|
||||
|
||||
.wx-progress-wrapper.wx-GKbcLEGA {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: transparent;
|
||||
border-radius: var(--wx-gantt-bar-border-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wx-progress-percent.wx-GKbcLEGA {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wx-progress-marker.wx-GKbcLEGA {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 80%;
|
||||
width: var(--wx-icon-size);
|
||||
height: var(--wx-gantt-progress-marker-height);
|
||||
background: var(--wx-gantt-progress-border-color);
|
||||
clip-path: polygon(50% 0, 100% 30%, 100% 100%, 0 100%, 0 30%);
|
||||
color: var(--wx-color-font);
|
||||
z-index: 3;
|
||||
font-size: calc(var(--wx-font-size-sm) - 2px);
|
||||
border-radius: 4px;
|
||||
cursor: ew-resize;
|
||||
text-align: center;
|
||||
line-height: 3;
|
||||
}
|
||||
.wx-progress-marker.wx-GKbcLEGA::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: calc(var(--wx-icon-size) - 2px);
|
||||
height: calc(var(--wx-gantt-progress-marker-height) - 2px);
|
||||
clip-path: polygon(50% 0, 100% 30%, 100% 100%, 0 100%, 0 30%);
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
background: var(--wx-gantt-link-marker-background);
|
||||
z-index: -1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wx-bar:hover .wx-progress-marker.wx-GKbcLEGA,
|
||||
.wx-progress-marker.wx-progress-in-drag.wx-GKbcLEGA {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wx-task .wx-progress-percent.wx-GKbcLEGA {
|
||||
background-color: var(--wx-gantt-task-fill-color);
|
||||
}
|
||||
|
||||
.wx-summary .wx-progress-percent.wx-GKbcLEGA {
|
||||
background-color: var(--wx-gantt-summary-fill-color);
|
||||
}
|
||||
|
||||
.wx-link.wx-GKbcLEGA {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--wx-gantt-link-marker-color);
|
||||
background-color: var(--wx-gantt-link-marker-background);
|
||||
opacity: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wx-link .wx-inner.wx-GKbcLEGA {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--wx-gantt-link-marker-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wx-link.wx-left.wx-GKbcLEGA {
|
||||
left: -16px;
|
||||
}
|
||||
|
||||
.wx-link.wx-right.wx-GKbcLEGA {
|
||||
right: -16px;
|
||||
}
|
||||
|
||||
.wx-link.wx-target:hover.wx-GKbcLEGA,
|
||||
.wx-link.wx-selected.wx-GKbcLEGA,
|
||||
.wx-bar:hover .wx-link.wx-target.wx-GKbcLEGA,
|
||||
.wx-link.wx-visible.wx-target.wx-GKbcLEGA {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wx-link.wx-selected.wx-GKbcLEGA {
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
.wx-link.wx-selected .wx-inner.wx-GKbcLEGA {
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
.wx-milestone .wx-link.wx-left.wx-GKbcLEGA {
|
||||
left: -16px;
|
||||
}
|
||||
.wx-milestone .wx-link.wx-right.wx-GKbcLEGA {
|
||||
right: -16px;
|
||||
}
|
||||
|
||||
.wx-cut.wx-GKbcLEGA {
|
||||
opacity: 50%;
|
||||
}
|
||||
.wx-bar:not(.wx-milestone):focus.wx-GKbcLEGA {
|
||||
outline: 1px solid var(--wx-color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.wx-milestone:focus.wx-GKbcLEGA {
|
||||
outline: none;
|
||||
}
|
||||
.wx-milestone:focus .wx-content.wx-GKbcLEGA {
|
||||
outline: 1px solid var(--wx-color-primary);
|
||||
outline-offset: 1.6px;
|
||||
}
|
||||
593
src/components/chart/Bars.jsx
Normal file
593
src/components/chart/Bars.jsx
Normal file
@@ -0,0 +1,593 @@
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { locate, locateID } from '@svar-ui/lib-dom';
|
||||
import { getID } from '../../helpers/locate';
|
||||
import storeContext from '../../context';
|
||||
import { useStore, useStoreWithCounter } from '@svar-ui/lib-react';
|
||||
import './Bars.css';
|
||||
|
||||
function Bars(props) {
|
||||
const { readonly, taskTemplate: TaskTemplate } = props;
|
||||
|
||||
const api = useContext(storeContext);
|
||||
|
||||
const [rTasksValue, rTasksCounter] = useStoreWithCounter(api,"_tasks");
|
||||
const [rLinksValue, rLinksCounter] = useStoreWithCounter(api,"_links");
|
||||
const areaValue = useStore(api,"area");
|
||||
const scalesValue = useStore(api,"_scales");
|
||||
const taskTypesValue = useStore(api,"taskTypes");
|
||||
const baselinesValue = useStore(api,"baselines");
|
||||
const selectedValue = useStore(api,"_selected");
|
||||
const scrollTaskStore = useStore(api,"_scrollTask" );
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
if (!areaValue || !Array.isArray(rTasksValue)) return [];
|
||||
const start = areaValue.start ?? 0;
|
||||
const end = areaValue.end ?? 0;
|
||||
return rTasksValue.slice(start, end).map((a) => ({ ...a }));
|
||||
}, [rTasksCounter, areaValue]);
|
||||
|
||||
const lengthUnitWidth = useMemo(
|
||||
() => scalesValue.lengthUnitWidth,
|
||||
[scalesValue],
|
||||
);
|
||||
|
||||
const ignoreNextClickRef = useRef(false);
|
||||
|
||||
const [linkFrom, setLinkFrom] = useState(undefined);
|
||||
const [taskMove, setTaskMove] = useState(null);
|
||||
const progressFromRef = useRef(null);
|
||||
|
||||
const [touched, setTouched] = useState(undefined);
|
||||
const touchTimerRef = useRef(null);
|
||||
|
||||
const [totalWidth, setTotalWidth] = useState(0);
|
||||
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const hasFocus = useMemo(() => {
|
||||
const el = containerRef.current;
|
||||
return !!(
|
||||
selectedValue.length &&
|
||||
el &&
|
||||
el.contains(document.activeElement)
|
||||
);
|
||||
}, [selectedValue, containerRef.current]);
|
||||
|
||||
const focused = useMemo(() => {
|
||||
return hasFocus && selectedValue[selectedValue.length - 1]?.id;
|
||||
}, [hasFocus, selectedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollTaskStore || typeof scrollTaskStore.subscribe !== 'function')
|
||||
return;
|
||||
const unsub = scrollTaskStore.subscribe((value) => {
|
||||
if (hasFocus && value) {
|
||||
const { id } = value;
|
||||
const node = containerRef.current?.querySelector(
|
||||
`.wx-bar[data-id='${id}']`,
|
||||
);
|
||||
if (node) node.focus();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
if (unsub) unsub();
|
||||
};
|
||||
}, [scrollTaskStore, hasFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
setTotalWidth(el.offsetWidth || 0);
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
if (entries[0]) {
|
||||
setTotalWidth(entries[0].contentRect.width);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}
|
||||
}, [containerRef.current]);
|
||||
|
||||
const startDrag = useCallback(() => {
|
||||
document.body.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
document.body.style.userSelect = '';
|
||||
}, []);
|
||||
|
||||
const getMoveMode = useCallback(
|
||||
(node, e, task) => {
|
||||
if (!task) task = api.getTask(getID(node));
|
||||
if (task.type === 'milestone' || task.type == 'summary') return '';
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const p = (e.clientX - rect.left) / rect.width;
|
||||
let delta = 0.2 / (rect.width > 200 ? rect.width / 200 : 1);
|
||||
|
||||
if (p < delta) return 'start';
|
||||
if (p > 1 - delta) return 'end';
|
||||
return '';
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const down = useCallback(
|
||||
(node, point) => {
|
||||
const { clientX } = point;
|
||||
const id = getID(node);
|
||||
const task = api.getTask(id);
|
||||
const css = point.target.classList;
|
||||
|
||||
if (!readonly) {
|
||||
if (css.contains('wx-progress-marker')) {
|
||||
const { progress } = api.getTask(id);
|
||||
progressFromRef.current = {
|
||||
id,
|
||||
x: clientX,
|
||||
progress,
|
||||
dx: 0,
|
||||
node,
|
||||
marker: point.target,
|
||||
};
|
||||
point.target.classList.add('wx-progress-in-drag');
|
||||
} else {
|
||||
const mode = getMoveMode(node, point, task) || 'move';
|
||||
|
||||
setTaskMove({
|
||||
id,
|
||||
mode,
|
||||
x: clientX,
|
||||
dx: 0,
|
||||
l: task.$x,
|
||||
w: task.$w,
|
||||
});
|
||||
}
|
||||
startDrag();
|
||||
}
|
||||
},
|
||||
[api, readonly, getMoveMode, startDrag],
|
||||
);
|
||||
|
||||
const mousedown = useCallback(
|
||||
(e) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const node = locate(e);
|
||||
if (!node) return;
|
||||
|
||||
down(node, e);
|
||||
},
|
||||
[readonly, lengthUnitWidth, totalWidth, taskMove, linkFrom],
|
||||
);
|
||||
|
||||
const touchstart = useCallback(
|
||||
(e) => {
|
||||
const node = locate(e);
|
||||
if (node) {
|
||||
touchTimerRef.current = setTimeout(() => {
|
||||
setTouched(true);
|
||||
down(node, e.touches[0]);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[readonly],
|
||||
);
|
||||
|
||||
|
||||
const up = useCallback(() => {
|
||||
if (progressFromRef.current) {
|
||||
const { dx, id, marker, value } = progressFromRef.current;
|
||||
progressFromRef.current = null;
|
||||
if (typeof value != 'undefined' && dx)
|
||||
api.exec('update-task', { id, task: { progress: value } });
|
||||
marker.classList.remove('wx-progress-in-drag');
|
||||
|
||||
ignoreNextClickRef.current = true;
|
||||
endDrag();
|
||||
} else if (taskMove) {
|
||||
const { id, mode, dx, l, w, start } = taskMove;
|
||||
setTaskMove(null);
|
||||
if (start) {
|
||||
const diff = Math.round(dx / lengthUnitWidth);
|
||||
|
||||
if (!diff) {
|
||||
api.exec('drag-task', {
|
||||
id,
|
||||
width: w,
|
||||
left: l,
|
||||
inProgress: false,
|
||||
});
|
||||
} else {
|
||||
let update = {};
|
||||
let task = api.getTask(id);
|
||||
if (mode == 'move') {
|
||||
update.start = task.start;
|
||||
update.end = task.end;
|
||||
} else update[mode] = task[mode];
|
||||
|
||||
api.exec('update-task', {
|
||||
id,
|
||||
task: update,
|
||||
diff,
|
||||
});
|
||||
}
|
||||
ignoreNextClickRef.current = true;
|
||||
}
|
||||
|
||||
endDrag();
|
||||
}
|
||||
}, [api, endDrag, taskMove, lengthUnitWidth]);
|
||||
|
||||
const move = useCallback(
|
||||
(e, point) => {
|
||||
const { clientX } = point;
|
||||
|
||||
if (!readonly) {
|
||||
if (progressFromRef.current) {
|
||||
const { node, x, id } = progressFromRef.current;
|
||||
const dx = (progressFromRef.current.dx = clientX - x);
|
||||
|
||||
const diff = Math.round((dx / node.offsetWidth) * 100);
|
||||
let progress = progressFromRef.current.progress + diff;
|
||||
progressFromRef.current.value = progress = Math.min(
|
||||
Math.max(0, progress),
|
||||
100,
|
||||
);
|
||||
|
||||
api.exec('update-task', {
|
||||
id,
|
||||
task: { progress },
|
||||
inProgress: true,
|
||||
});
|
||||
} else if (taskMove) {
|
||||
const { mode, l, w, x, id, start } = taskMove;
|
||||
const dx = clientX - x;
|
||||
if (
|
||||
(!start && Math.abs(dx) < 20) ||
|
||||
(mode === 'start' && w - dx < lengthUnitWidth) ||
|
||||
(mode === 'end' && w + dx < lengthUnitWidth) ||
|
||||
(mode == 'move' &&
|
||||
((dx < 0 && l + dx < 0) || (dx > 0 && l + w + dx > totalWidth)))
|
||||
)
|
||||
return;
|
||||
|
||||
const nextTaskMove = { ...taskMove, dx };
|
||||
|
||||
let left, width;
|
||||
if (mode === 'start') {
|
||||
left = l + dx;
|
||||
width = w - dx;
|
||||
} else if (mode === 'end') {
|
||||
left = l;
|
||||
width = w + dx;
|
||||
} else if (mode === 'move') {
|
||||
left = l + dx;
|
||||
width = w;
|
||||
}
|
||||
|
||||
let ev = {
|
||||
id,
|
||||
width: width,
|
||||
left: left,
|
||||
inProgress: true,
|
||||
};
|
||||
|
||||
api.exec('drag-task', ev);
|
||||
|
||||
const task = api.getTask(id);
|
||||
if (
|
||||
!nextTaskMove.start &&
|
||||
((mode == 'move' && task.$x == l) ||
|
||||
(mode != 'move' && task.$w == w))
|
||||
) {
|
||||
ignoreNextClickRef.current = true;
|
||||
up();
|
||||
return;
|
||||
}
|
||||
nextTaskMove.start = true;
|
||||
setTaskMove(nextTaskMove);
|
||||
} else {
|
||||
const mnode = locate(e);
|
||||
if (mnode) {
|
||||
const mode = getMoveMode(mnode, point);
|
||||
mnode.style.cursor = mode && !readonly ? 'col-resize' : 'pointer';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[api, readonly, taskMove, lengthUnitWidth, totalWidth, getMoveMode],
|
||||
);
|
||||
|
||||
const mousemove = useCallback(
|
||||
(e) => {
|
||||
move(e, e);
|
||||
},
|
||||
[move],
|
||||
);
|
||||
|
||||
const touchmove = useCallback(
|
||||
(e) => {
|
||||
if (touched) {
|
||||
e.preventDefault();
|
||||
move(e, e.touches[0]);
|
||||
} else if (touchTimerRef.current) {
|
||||
clearTimeout(touchTimerRef.current);
|
||||
touchTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[touched, move],
|
||||
);
|
||||
|
||||
const mouseup = useCallback(() => {
|
||||
up();
|
||||
}, [up]);
|
||||
|
||||
const touchend = useCallback(() => {
|
||||
setTouched(null);
|
||||
if (touchTimerRef.current) {
|
||||
clearTimeout(touchTimerRef.current);
|
||||
touchTimerRef.current = null;
|
||||
}
|
||||
up();
|
||||
}, [up]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', mouseup);
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', mouseup);
|
||||
};
|
||||
}, [mouseup]);
|
||||
|
||||
const onDblClick = useCallback(
|
||||
(e) => {
|
||||
if (!readonly) {
|
||||
const id = locateID(e.target);
|
||||
if (id && !e.target.classList.contains('wx-link'))
|
||||
api.exec('show-editor', { id });
|
||||
}
|
||||
},
|
||||
[api, readonly],
|
||||
);
|
||||
|
||||
|
||||
const types = ['e2s', 's2s', 'e2e', 's2e'];
|
||||
const getLinkType = useCallback(
|
||||
(fromStart, toStart) => {
|
||||
return types[(fromStart ? 1 : 0) + (toStart ? 0 : 2)];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const alreadyLinked = useCallback(
|
||||
(target, toStart) => {
|
||||
const source = linkFrom.id;
|
||||
const fromStart = linkFrom.start;
|
||||
|
||||
if (target === source) return true;
|
||||
|
||||
return !!rLinksValue.find((l) => {
|
||||
return (
|
||||
l.target == target &&
|
||||
l.source == source &&
|
||||
l.type === getLinkType(fromStart, toStart)
|
||||
);
|
||||
});
|
||||
},
|
||||
[linkFrom, rLinksCounter, getLinkType],
|
||||
);
|
||||
|
||||
const removeLinkMarker = useCallback(() => {
|
||||
if (linkFrom) {
|
||||
setLinkFrom(null);
|
||||
}
|
||||
}, [linkFrom]);
|
||||
|
||||
const onClick = useCallback(
|
||||
(e) => {
|
||||
if (ignoreNextClickRef.current) {
|
||||
ignoreNextClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const id = locateID(e.target);
|
||||
if (id) {
|
||||
const css = e.target.classList;
|
||||
if (css.contains('wx-link')) {
|
||||
const toStart = css.contains('wx-left');
|
||||
if (!linkFrom) {
|
||||
setLinkFrom({ id, start: toStart });
|
||||
return;
|
||||
}
|
||||
|
||||
if (linkFrom.id !== id && !alreadyLinked(id, toStart)) {
|
||||
api.exec('add-link', {
|
||||
link: {
|
||||
source: linkFrom.id,
|
||||
target: id,
|
||||
type: getLinkType(linkFrom.start, toStart),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
api.exec('select-task', {
|
||||
id,
|
||||
toggle: e.ctrlKey || e.metaKey,
|
||||
range: e.shiftKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
removeLinkMarker();
|
||||
},
|
||||
[api, linkFrom, rLinksCounter],
|
||||
);
|
||||
|
||||
const taskStyle = useCallback((task) => {
|
||||
return {
|
||||
left: `${task.$x}px`,
|
||||
top: `${task.$y}px`,
|
||||
width: `${task.$w}px`,
|
||||
height: `${task.$h}px`,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const baselineStyle = useCallback((task) => {
|
||||
return {
|
||||
left: `${task.$x_base}px`,
|
||||
top: `${task.$y_base}px`,
|
||||
width: `${task.$w_base}px`,
|
||||
height: `${task.$h_base}px`,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextmenu = useCallback(
|
||||
(ev) => {
|
||||
if (touched || touchTimerRef.current) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[touched],
|
||||
);
|
||||
|
||||
|
||||
const taskTypeCss = useCallback(
|
||||
(type) => {
|
||||
let css = taskTypesValue.some((t) => type === t.id) ? type : 'task';
|
||||
if (css !== 'task' && css !== 'milestone' && css !== 'summary')
|
||||
css = `task ${css}`;
|
||||
return css;
|
||||
},
|
||||
[taskTypesValue],
|
||||
);
|
||||
|
||||
const forward = useCallback(
|
||||
(ev) => {
|
||||
api.exec(ev.action, ev.data);
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="wx-GKbcLEGA wx-bars"
|
||||
style={{ lineHeight: `${tasks.length ? tasks[0].$h : 0}px` }}
|
||||
ref={containerRef}
|
||||
onContextMenu={contextmenu}
|
||||
onMouseDown={mousedown}
|
||||
onMouseMove={mousemove}
|
||||
onTouchStart={touchstart}
|
||||
onTouchMove={touchmove}
|
||||
onTouchEnd={touchend}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDblClick}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{tasks.map((task) => {
|
||||
if (task.$skip) return null;
|
||||
const barClass =
|
||||
`wx-bar wx-${taskTypeCss(task.type)}` +
|
||||
(touched && taskMove && task.id === taskMove.id ? ' wx-touch' : '') +
|
||||
(linkFrom && linkFrom.id === task.id ? ' wx-selected' : '') +
|
||||
(task.$reorder ? ' wx-reorder-task' : '');
|
||||
const leftLinkClass =
|
||||
'wx-link wx-left' +
|
||||
(linkFrom ? ' wx-visible' : '') +
|
||||
(!linkFrom || !alreadyLinked(task.id, true) ? ' wx-target' : '') +
|
||||
(linkFrom && linkFrom.id === task.id && linkFrom.start
|
||||
? ' wx-selected'
|
||||
: '');
|
||||
const rightLinkClass =
|
||||
'wx-link wx-right' +
|
||||
(linkFrom ? ' wx-visible' : '') +
|
||||
(!linkFrom || !alreadyLinked(task.id, false) ? ' wx-target' : '') +
|
||||
(linkFrom && linkFrom.id === task.id && !linkFrom.start
|
||||
? ' wx-selected'
|
||||
: '');
|
||||
return (
|
||||
<Fragment key={task.id}>
|
||||
<div
|
||||
className={'wx-GKbcLEGA ' + barClass}
|
||||
style={taskStyle(task)}
|
||||
data-tooltip-id={task.id}
|
||||
data-id={task.id}
|
||||
tabIndex={focused === task.id ? 0 : -1}
|
||||
>
|
||||
{!readonly ? (
|
||||
<div className={'wx-GKbcLEGA ' + leftLinkClass}>
|
||||
<div className="wx-GKbcLEGA wx-inner"></div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{task.type !== 'milestone' ? (
|
||||
<>
|
||||
{task.progress ? (
|
||||
<div className="wx-GKbcLEGA wx-progress-wrapper">
|
||||
<div
|
||||
className="wx-GKbcLEGA wx-progress-percent"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
) : null}
|
||||
{!readonly ? (
|
||||
<div
|
||||
className="wx-GKbcLEGA wx-progress-marker"
|
||||
style={{ left: `calc(${task.progress}% - 10px)` }}
|
||||
>
|
||||
{task.progress}
|
||||
</div>
|
||||
) : null}
|
||||
{TaskTemplate ? (
|
||||
<TaskTemplate data={task} api={api} onAction={forward} />
|
||||
) : (
|
||||
<div className="wx-GKbcLEGA wx-content">
|
||||
{task.text || ''}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="wx-GKbcLEGA wx-content"></div>
|
||||
{TaskTemplate ? (
|
||||
<TaskTemplate data={task} api={api} onAction={forward} />
|
||||
) : (
|
||||
<div className="wx-GKbcLEGA wx-text-out">{task.text}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readonly ? (
|
||||
<div className={'wx-GKbcLEGA ' + rightLinkClass}>
|
||||
<div className="wx-GKbcLEGA wx-inner"></div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{baselinesValue && !task.$skip_baseline ? (
|
||||
<div
|
||||
className={
|
||||
'wx-GKbcLEGA wx-baseline' +
|
||||
(task.type === 'milestone' ? ' wx-milestone' : '')
|
||||
}
|
||||
style={baselineStyle(task)}
|
||||
></div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Bars;
|
||||
42
src/components/chart/CellGrid.jsx
Normal file
42
src/components/chart/CellGrid.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import storeContext from '../../context';
|
||||
import { grid } from '@svar-ui/gantt-store';
|
||||
import { useStore } from '@svar-ui/lib-react';
|
||||
|
||||
|
||||
function CellGrid({ borders = '' }) {
|
||||
const api = useContext(storeContext);
|
||||
const cellWidth = useStore(api, "cellWidth");
|
||||
const cellHeight = useStore(api, "cellHeight");
|
||||
|
||||
const nodeRef = useRef(null);
|
||||
const [color, setColor] = useState('#e4e4e4');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof getComputedStyle !== 'undefined' && nodeRef.current) {
|
||||
const border = getComputedStyle(nodeRef.current).getPropertyValue(
|
||||
'--wx-gantt-border',
|
||||
);
|
||||
setColor(border ? border.substring(border.indexOf('#')) : '#1d1e261a');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const style = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background:
|
||||
cellWidth != null && cellHeight != null
|
||||
? `url(${grid(cellWidth, cellHeight, color, borders)})`
|
||||
: undefined,
|
||||
position: 'absolute',
|
||||
};
|
||||
|
||||
return <div ref={nodeRef} style={style} />;
|
||||
}
|
||||
|
||||
export default CellGrid;
|
||||
66
src/components/chart/Chart.css
Normal file
66
src/components/chart/Chart.css
Normal file
@@ -0,0 +1,66 @@
|
||||
.wx-chart.wx-mR7v2Xag {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.wx-markers.wx-mR7v2Xag {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.wx-marker.wx-mR7v2Xag {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.wx-default.wx-mR7v2Xag {
|
||||
background: var(--wx-gantt-marker-color);
|
||||
}
|
||||
|
||||
.wx-content.wx-mR7v2Xag {
|
||||
position: absolute;
|
||||
min-width: 50px;
|
||||
padding: 4px 8px;
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
font: var(--wx-gantt-marker-font);
|
||||
color: var(--wx-gantt-marker-font-color);
|
||||
background-color: inherit;
|
||||
white-space: nowrap;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.wx-area.wx-mR7v2Xag {
|
||||
position: relative;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.wx-selected.wx-mR7v2Xag {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: var(--wx-gantt-select-color);
|
||||
}
|
||||
|
||||
.wx-cut.wx-mR7v2Xag {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
.wx-gantt-holidays.wx-mR7v2Xag {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.wx-weekend.wx-mR7v2Xag {
|
||||
height: 100%;
|
||||
background: var(--wx-gantt-holiday-background);
|
||||
color: var(--wx-gantt-holiday-color);
|
||||
position: absolute;
|
||||
}
|
||||
281
src/components/chart/Chart.jsx
Normal file
281
src/components/chart/Chart.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useContext,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import CellGrid from './CellGrid.jsx';
|
||||
import Bars from './Bars.jsx';
|
||||
import Links from './Links.jsx';
|
||||
import { hotkeys } from '@svar-ui/grid-store';
|
||||
import storeContext from '../../context';
|
||||
import { useStore, useStoreWithCounter } from '@svar-ui/lib-react';
|
||||
import './Chart.css';
|
||||
|
||||
function Chart(props) {
|
||||
const {
|
||||
readonly,
|
||||
fullWidth,
|
||||
fullHeight,
|
||||
taskTemplate,
|
||||
cellBorders,
|
||||
highlightTime,
|
||||
} = props;
|
||||
|
||||
const api = useContext(storeContext);
|
||||
|
||||
const [selected, selectedCounter] = useStoreWithCounter(api, "_selected");
|
||||
const rScrollLeft = useStore(api, "scrollLeft");
|
||||
const rScrollTop = useStore(api, "scrollTop");
|
||||
const cellHeight = useStore(api, "cellHeight");
|
||||
const cellWidth = useStore(api, "cellWidth");
|
||||
const scales = useStore(api, "_scales");
|
||||
const markers = useStore(api, "_markers");
|
||||
const rScrollTask = useStore(api, "_scrollTask");
|
||||
const zoom = useStore(api, "zoom");
|
||||
|
||||
const [chartHeight, setChartHeight] = useState();
|
||||
const [scrollLeft, setScrollLeft] = useState();
|
||||
const [scrollTop, setScrollTop] = useState();
|
||||
const chartRef = useRef(null);
|
||||
|
||||
const extraRows = 1;
|
||||
useEffect(() => {
|
||||
setScrollLeft(rScrollLeft);
|
||||
setScrollTop(rScrollTop);
|
||||
}, [rScrollLeft, rScrollTop]);
|
||||
|
||||
|
||||
const selectStyle = useMemo(() => {
|
||||
const t = [];
|
||||
if (selected && selected.length && cellHeight) {
|
||||
selected.forEach((obj) => {
|
||||
t.push({ height: `${cellHeight}px`, top: `${obj.$y - 3}px` });
|
||||
});
|
||||
}
|
||||
return t;
|
||||
}, [selectedCounter, cellHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
dataRequest();
|
||||
}, [chartHeight]);
|
||||
|
||||
const chartGridHeight = useMemo(
|
||||
() => Math.max(chartHeight || 0, fullHeight),
|
||||
[chartHeight, fullHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = chartRef.current;
|
||||
if (!el) return;
|
||||
if (typeof scrollTop === 'number') el.scrollTop = scrollTop;
|
||||
if (typeof scrollLeft === 'number') el.scrollLeft = scrollLeft;
|
||||
if (typeof scrollTop === 'number' && scrollTop !== el.scrollTop)
|
||||
setScroll({ top: true });
|
||||
if (typeof scrollLeft === 'number' && scrollLeft !== el.scrollLeft)
|
||||
setScroll({ left: true });
|
||||
}, [scrollTop, scrollLeft]);
|
||||
|
||||
const onScroll = () => {
|
||||
const scroll = { left: true, top: true };
|
||||
setScroll(scroll);
|
||||
dataRequest();
|
||||
};
|
||||
|
||||
function setScroll(scroll) {
|
||||
const el = chartRef.current;
|
||||
if (!el) return;
|
||||
const pos = {};
|
||||
if (scroll.top) pos.top = el.scrollTop;
|
||||
if (scroll.left) pos.left = el.scrollLeft;
|
||||
api.exec('scroll-chart', pos);
|
||||
}
|
||||
|
||||
function dataRequest() {
|
||||
const el = chartRef.current;
|
||||
const clientHeightLocal = chartHeight || 0;
|
||||
const num = Math.ceil(clientHeightLocal / (cellHeight || 1)) + 1;
|
||||
const pos = Math.floor(((el && el.scrollTop) || 0) / (cellHeight || 1));
|
||||
const start = Math.max(0, pos - extraRows);
|
||||
const end = pos + num + extraRows;
|
||||
const from = start * (cellHeight || 0);
|
||||
api.exec('render-data', {
|
||||
start,
|
||||
end,
|
||||
from,
|
||||
});
|
||||
}
|
||||
|
||||
const showTask = useCallback(
|
||||
(value) => {
|
||||
if (!value) return;
|
||||
|
||||
const { id, mode } = value;
|
||||
|
||||
if (mode.toString().indexOf('x') < 0) return;
|
||||
const el = chartRef.current;
|
||||
if (!el) return;
|
||||
const { clientWidth } = el;
|
||||
const task = api.getTask(id);
|
||||
if (task.$x + task.$w < (scrollLeft || 0)) {
|
||||
setScrollLeft(task.$x - (cellWidth || 0));
|
||||
} else if (task.$x >= clientWidth + (scrollLeft || 0)) {
|
||||
const width = clientWidth < task.$w ? cellWidth || 0 : task.$w;
|
||||
setScrollLeft(task.$x - clientWidth + width);
|
||||
}
|
||||
},
|
||||
[api, scrollLeft, cellWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
showTask(rScrollTask);
|
||||
}, [rScrollTask]);
|
||||
|
||||
|
||||
function onWheel(e) {
|
||||
if (zoom && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
const el = chartRef.current;
|
||||
const dir = -Math.sign(e.deltaY);
|
||||
const offset = e.clientX - (el ? el.getBoundingClientRect().left : 0);
|
||||
api.exec('zoom-scale', {
|
||||
dir,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getHoliday(cell) {
|
||||
const style = highlightTime(cell.date, cell.unit);
|
||||
if (style)
|
||||
return {
|
||||
css: style,
|
||||
width: cell.width,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
const holidays = useMemo(() => {
|
||||
return scales &&
|
||||
(scales.minUnit === 'hour' || scales.minUnit === 'day') &&
|
||||
highlightTime
|
||||
? scales.rows[scales.rows.length - 1].cells.map(getHoliday)
|
||||
: null;
|
||||
}, [scales, highlightTime]);
|
||||
|
||||
function handleHotkey(ev) {
|
||||
ev.eventSource = 'chart';
|
||||
api.exec('hotkey', ev);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = chartRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setChartHeight(el.clientHeight);
|
||||
update();
|
||||
const ro = new ResizeObserver(() => update());
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [chartRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = chartRef.current;
|
||||
if (!el) return;
|
||||
const cleanup = hotkeys(el, {
|
||||
keys: {
|
||||
arrowup: true,
|
||||
arrowdown: true,
|
||||
},
|
||||
exec: (v) => handleHotkey(v),
|
||||
});
|
||||
return () => {
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
};
|
||||
}, [chartRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = chartRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const handler = onWheel;
|
||||
node.addEventListener('wheel', handler);
|
||||
return () => {
|
||||
node.removeEventListener('wheel', handler);
|
||||
};
|
||||
}, [onWheel])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="wx-mR7v2Xag wx-chart"
|
||||
tabIndex={-1}
|
||||
ref={chartRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{markers && markers.length ? (
|
||||
<div
|
||||
className="wx-mR7v2Xag wx-markers"
|
||||
style={{ height: `${chartGridHeight}px` }}
|
||||
>
|
||||
{markers.map((marker, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`wx-mR7v2Xag wx-marker ${marker.css || 'wx-default'}`}
|
||||
style={{ left: `${marker.left}px` }}
|
||||
>
|
||||
<div className="wx-mR7v2Xag wx-content">{marker.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="wx-mR7v2Xag wx-area"
|
||||
style={{ width: `${fullWidth}px`, height: `${chartGridHeight}px` }}
|
||||
>
|
||||
{holidays ? (
|
||||
<div
|
||||
className="wx-mR7v2Xag wx-gantt-holidays"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{holidays.map((holiday, i) =>
|
||||
holiday ? (
|
||||
<div
|
||||
key={i}
|
||||
className={'wx-mR7v2Xag ' + holiday.css}
|
||||
style={{
|
||||
width: `${holiday.width}px`,
|
||||
left: `${i * holiday.width}px`,
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CellGrid borders={cellBorders} />
|
||||
|
||||
{selected && selected.length
|
||||
? selected.map((obj, index) =>
|
||||
obj.$y ? (
|
||||
<div
|
||||
key={obj.id}
|
||||
className="wx-mR7v2Xag wx-selected"
|
||||
data-id={obj.id}
|
||||
style={selectStyle[index]}
|
||||
></div>
|
||||
) : null,
|
||||
)
|
||||
: null}
|
||||
|
||||
<Links />
|
||||
<Bars readonly={readonly} taskTemplate={taskTemplate} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Chart;
|
||||
18
src/components/chart/Links.css
Normal file
18
src/components/chart/Links.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.wx-links.wx-dkx3NwEn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wx-line.wx-dkx3NwEn {
|
||||
user-select: auto;
|
||||
pointer-events: stroke;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
stroke: var(--wx-gantt-link-color);
|
||||
stroke-width: 2;
|
||||
z-index: 0;
|
||||
fill: transparent;
|
||||
}
|
||||
21
src/components/chart/Links.jsx
Normal file
21
src/components/chart/Links.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useContext } from 'react';
|
||||
import storeContext from '../../context';
|
||||
import { useStore } from '@svar-ui/lib-react';
|
||||
import './Links.css';
|
||||
|
||||
export default function Links() {
|
||||
const api = useContext(storeContext);
|
||||
const links = useStore(api,"_links");
|
||||
|
||||
return (
|
||||
<svg className="wx-dkx3NwEn wx-links">
|
||||
{(links || []).map((link) => (
|
||||
<polyline
|
||||
className="wx-dkx3NwEn wx-line"
|
||||
points={link.$p}
|
||||
key={link.id}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
4
src/components/editor/DateTimePicker.css
Normal file
4
src/components/editor/DateTimePicker.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.date-time-controll.wx-hFsbgDln {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
31
src/components/editor/DateTimePicker.jsx
Normal file
31
src/components/editor/DateTimePicker.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { DatePicker, TimePicker } from '@svar-ui/react-core';
|
||||
import './DateTimePicker.css';
|
||||
|
||||
export default function DateTimePicker(props) {
|
||||
const { value, time, format, onchange, onChange, ...restProps } = props;
|
||||
const onChangeHandler = onChange ?? onchange;
|
||||
|
||||
function handleDateChange(ev) {
|
||||
const current = new Date(ev.value);
|
||||
current.setHours(value.getHours());
|
||||
current.setMinutes(value.getMinutes());
|
||||
|
||||
onChangeHandler && onChangeHandler({ value: current });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wx-hFsbgDln date-time-controll">
|
||||
<DatePicker
|
||||
{...restProps}
|
||||
value={value}
|
||||
onChange={handleDateChange}
|
||||
format={format}
|
||||
buttons={['today']}
|
||||
clear={false}
|
||||
/>
|
||||
{time ? (
|
||||
<TimePicker value={value} onChange={onChangeHandler} format={format} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/editor/Links.css
Normal file
38
src/components/editor/Links.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.wx-links.wx-j93aYGQf {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wx-cell.wx-j93aYGQf {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wx-task-name.wx-j93aYGQf {
|
||||
font-family: var(--wx-input-font-family);
|
||||
font-size: var(--wx-input-font-size);
|
||||
font-weight: var(--wx-input-font-weigth);
|
||||
color: var(--wx-input-font-color);
|
||||
width: 170px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wx-wrapper.wx-j93aYGQf {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wx-delete-icon.wx-j93aYGQf {
|
||||
margin-left: 12px;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
|
||||
font-size: var(--wx-icon-size);
|
||||
cursor: pointer;
|
||||
color: var(--wx-gantt-icon-color);
|
||||
}
|
||||
|
||||
.wx-delete-icon.wx-j93aYGQf:hover {
|
||||
color: var(--wx-color-primary);
|
||||
}
|
||||
142
src/components/editor/Links.jsx
Normal file
142
src/components/editor/Links.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect, useMemo, useContext } from 'react';
|
||||
import { Field, Combo } from '@svar-ui/react-core';
|
||||
import { context } from '@svar-ui/react-core';
|
||||
import { useStore } from '@svar-ui/lib-react';
|
||||
import './Links.css';
|
||||
|
||||
export default function Links({ api, autoSave, onLinksChange }) {
|
||||
const i18n = useContext(context.i18n);
|
||||
const _ = i18n.getGroup('gantt');
|
||||
|
||||
const activeTask = useStore(api,"activeTask");
|
||||
const links = useStore(api,"_links");
|
||||
|
||||
const [linksData, setLinksData] = useState();
|
||||
|
||||
function getLinksData() {
|
||||
if (activeTask) {
|
||||
const inLinks = links
|
||||
.filter((a) => a.target == activeTask)
|
||||
.map((link) => ({ link, task: api.getTask(link.source) }));
|
||||
|
||||
const outLinks = links
|
||||
.filter((a) => a.source == activeTask)
|
||||
.map((link) => ({ link, task: api.getTask(link.target) }));
|
||||
|
||||
return [
|
||||
{ title: _('Predecessors'), data: inLinks },
|
||||
{ title: _('Successors'), data: outLinks },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLinksData(getLinksData());
|
||||
}, [activeTask, links]);
|
||||
|
||||
const list = useMemo(
|
||||
() => [
|
||||
{ id: 'e2s', label: _('End-to-start') },
|
||||
{ id: 's2s', label: _('Start-to-start') },
|
||||
{ id: 'e2e', label: _('End-to-end') },
|
||||
{ id: 's2e', label: _('Start-to-end') },
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
function deleteLink(id) {
|
||||
if (autoSave) {
|
||||
api.exec('delete-link', { id });
|
||||
} else {
|
||||
setLinksData((prev) =>
|
||||
(prev || []).map((group) => ({
|
||||
...group,
|
||||
data: group.data.filter((item) => item.link.id !== id),
|
||||
})),
|
||||
);
|
||||
onLinksChange &&
|
||||
onLinksChange({
|
||||
id,
|
||||
action: 'delete-link',
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(ev, id) {
|
||||
const value = ev.value;
|
||||
if (autoSave) {
|
||||
api.exec('update-link', {
|
||||
id,
|
||||
link: { type: value },
|
||||
});
|
||||
} else {
|
||||
setLinksData((prev) =>
|
||||
(prev || []).map((group) => ({
|
||||
...group,
|
||||
data: group.data.map((item) =>
|
||||
item.link.id === id
|
||||
? { ...item, link: { ...item.link, type: value } }
|
||||
: item,
|
||||
),
|
||||
})),
|
||||
);
|
||||
onLinksChange &&
|
||||
onLinksChange({
|
||||
id,
|
||||
action: 'update-link',
|
||||
data: {
|
||||
id,
|
||||
link: { type: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(linksData || []).map((group, idx) =>
|
||||
group.data.length ? (
|
||||
<div className="wx-j93aYGQf wx-links" key={idx}>
|
||||
<Field label={group.title} position="top">
|
||||
<table>
|
||||
<tbody>
|
||||
{group.data.map((obj) => (
|
||||
<tr key={obj.link.id}>
|
||||
<td className="wx-j93aYGQf wx-cell">
|
||||
<div className="wx-j93aYGQf wx-task-name">
|
||||
{obj.task.text || ''}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="wx-j93aYGQf wx-cell">
|
||||
<div className="wx-j93aYGQf wx-wrapper">
|
||||
<Combo
|
||||
value={obj.link.type}
|
||||
placeholder={_('Select link type')}
|
||||
options={list}
|
||||
onChange={(e) => handleChange(e, obj.link.id)}
|
||||
>
|
||||
{({ option }) => option.label}
|
||||
</Combo>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="wx-j93aYGQf wx-cell">
|
||||
<i
|
||||
className="wx-j93aYGQf wxi-delete wx-delete-icon"
|
||||
onClick={() => deleteLink(obj.link.id)}
|
||||
role="button"
|
||||
></i>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Field>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
src/components/grid/ActionCell.css
Normal file
11
src/components/grid/ActionCell.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.wx-action-icon.wx-9DAESAHW {
|
||||
cursor: pointer;
|
||||
font-size: var(--wx-icon-size);
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
display: block;
|
||||
color: var(--wx-gantt-icon-color);
|
||||
}
|
||||
.wx-action-icon.wx-9DAESAHW:hover {
|
||||
color: var(--wx-color-link);
|
||||
}
|
||||
17
src/components/grid/ActionCell.jsx
Normal file
17
src/components/grid/ActionCell.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import './ActionCell.css';
|
||||
|
||||
function ActionCell({ column, cell }) {
|
||||
const action = useMemo(() => column.id, [column?.id]);
|
||||
|
||||
return cell || column.id == 'add-task' ? (
|
||||
<div style={{ textAlign: column.align }}>
|
||||
<i
|
||||
className="wx-9DAESAHW wx-action-icon wxi-plus"
|
||||
data-action={action}
|
||||
></i>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default ActionCell;
|
||||
112
src/components/grid/Grid.css
Normal file
112
src/components/grid/Grid.css
Normal file
@@ -0,0 +1,112 @@
|
||||
.wx-table-container.wx-rHj6070p {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--wx-gantt-border);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
height: 100%;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
/*table*/
|
||||
.wx-table.wx-rHj6070p {
|
||||
--wx-table-select-background: var(--wx-gantt-select-color);
|
||||
--wx-table-select-focus-background: var(--wx-gantt-select-color);
|
||||
--wx-table-select-border: none;
|
||||
--wx-table-cell-border: var(--wx-grid-body-row-border);
|
||||
--wx-table-header-background: var(--wx-background);
|
||||
--wx-table-header-border: var(--wx-gantt-border);
|
||||
--wx-table-header-cell-border: var(--wx-gantt-border);
|
||||
}
|
||||
.wx-table .wx-grid .wx-table-box {
|
||||
border: none;
|
||||
}
|
||||
.wx-table .wx-grid .wx-scroll {
|
||||
overflow: visible !important;
|
||||
}
|
||||
.wx-table .wx-grid .wx-scroll .wx-body,
|
||||
.wx-table .wx-grid .wx-scroll .wx-header {
|
||||
width: 100% !important;
|
||||
}
|
||||
.wx-table .wx-grid {
|
||||
font: var(--wx-grid-body-font);
|
||||
color: var(--wx-grid-body-font-color);
|
||||
}
|
||||
/*body*/
|
||||
.wx-table .wx-grid .wx-cell {
|
||||
padding: 0 5px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wx-table .wx-grid .wx-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wx-table .wx-grid .wx-cell.wx-text-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.wx-table .wx-grid .wx-cell.wx-text-right {
|
||||
justify-content: end;
|
||||
}
|
||||
.wx-table .wx-grid .wx-body .wx-cell {
|
||||
border-right: var(--wx-grid-body-cell-border);
|
||||
}
|
||||
.wx-table .wx-grid .wx-cell:has(input, .wx-value) {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
/*header*/
|
||||
.wx-table .wx-grid .wx-header {
|
||||
box-shadow: var(--wx-grid-header-shadow);
|
||||
z-index: 1;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-cell {
|
||||
font: var(--wx-grid-header-font);
|
||||
text-transform: var(--wx-grid-header-text-transform);
|
||||
color: var(--wx-grid-header-font-color);
|
||||
padding: 0 5px;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-cell:first-child {
|
||||
padding-left: 14px;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-cell .wx-text {
|
||||
width: 100%;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-cell:has(.wx-sort) .wx-text {
|
||||
width: calc(100% - 15px);
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-cell.wx-text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-cell.wx-text-center {
|
||||
text-align: center;
|
||||
padding-left: 5px;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-cell.wx-text-center.wx-action {
|
||||
justify-content: center;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-cell.wx-text-right.wx-action {
|
||||
justify-content: right;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-action i {
|
||||
font-size: var(--wx-icon-size);
|
||||
color: var(--wx-gantt-icon-color);
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-action .wx-text {
|
||||
display: none;
|
||||
}
|
||||
.wx-table .wx-grid .wx-header .wx-action i:hover {
|
||||
color: var(--wx-color-link);
|
||||
}
|
||||
/*drag element*/
|
||||
.wx-table .wx-grid .wx-reorder-task.wx-row {
|
||||
width: 100%;
|
||||
background: var(--wx-background-alt);
|
||||
border-top: var(--wx-grid-body-row-border);
|
||||
}
|
||||
.wx-table .wx-grid .wx-reorder-task.wx-selected {
|
||||
background: var(--wx-gantt-select-color);
|
||||
border-top: transparent;
|
||||
border-bottom: transparent;
|
||||
}
|
||||
581
src/components/grid/Grid.jsx
Normal file
581
src/components/grid/Grid.jsx
Normal file
@@ -0,0 +1,581 @@
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { context } from '@svar-ui/react-core';
|
||||
import { locateID, locateAttr } from '@svar-ui/lib-dom';
|
||||
import { reorder } from '../../helpers/reorder';
|
||||
import { normalizeDates } from '@svar-ui/gantt-store';
|
||||
import { Grid as WxGrid } from '@svar-ui/react-grid';
|
||||
import TextCell from './TextCell.jsx';
|
||||
import ActionCell from './ActionCell.jsx';
|
||||
import { useWritableProp, useStore } from '@svar-ui/lib-react';
|
||||
import storeContext from '../../context';
|
||||
import './Grid.css';
|
||||
|
||||
export default function Grid(props) {
|
||||
const { readonly, compactMode, width = 0, display = 'all', columnWidth: columnWidthProp = 0, onTableAPIChange } = props;
|
||||
const [columnWidth, setColumnWidthProp] = useWritableProp(columnWidthProp);
|
||||
const [tableAPI, setTableAPI] = useState();
|
||||
|
||||
const i18n = useContext(context.i18n);
|
||||
const _ = useMemo(() => i18n.getGroup('gantt'), [i18n]);
|
||||
const api = useContext(storeContext);
|
||||
|
||||
const scrollTopVal = useStore(api, "scrollTop");
|
||||
const cellHeightVal = useStore(api, "cellHeight");
|
||||
const scrollTask = useStore(api, "_scrollTask");
|
||||
const selectedVal = useStore(api, "_selected");
|
||||
const areaVal = useStore(api, "area");
|
||||
const rTasksVal = useStore(api, "_tasks");
|
||||
const scalesVal = useStore(api, "_scales");
|
||||
const columnsVal = useStore(api, "columns");
|
||||
const sortVal = useStore(api, "_sort");
|
||||
const durationUnitVal = useStore(api, "durationUnit");
|
||||
|
||||
const touchYRef = useRef(null);
|
||||
const scrollRef = useRef(true);
|
||||
const [dragTask, setDragTask] = useState(null);
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
if (!rTasksVal || !areaVal) return [];
|
||||
return rTasksVal.slice(areaVal.start, areaVal.end);
|
||||
}, [rTasksVal, areaVal]);
|
||||
|
||||
const execAction = useCallback(
|
||||
(id, action) => {
|
||||
if (action === 'add-task') {
|
||||
api.exec(action, {
|
||||
target: id,
|
||||
task: { text: _('New Task') },
|
||||
mode: 'child',
|
||||
show: true,
|
||||
});
|
||||
} else if (action === 'open-task') {
|
||||
const task = tasks.find((a) => a.id === id);
|
||||
if (task?.data || task?.lazy)
|
||||
api.exec(action, { id, mode: !task.open });
|
||||
}
|
||||
},
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const onClick = useCallback(
|
||||
(e) => {
|
||||
const id = locateID(e);
|
||||
const action = e.target.dataset.action;
|
||||
if (action) e.preventDefault();
|
||||
if (id) {
|
||||
if (action === 'add-task' || action === 'open-task') {
|
||||
execAction(id, action);
|
||||
} else {
|
||||
api.exec('select-task', {
|
||||
id,
|
||||
toggle: e.ctrlKey || e.metaKey,
|
||||
range: e.shiftKey,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
} else if (action === 'add-task') {
|
||||
execAction(null, action);
|
||||
}
|
||||
},
|
||||
[api, execAction],
|
||||
);
|
||||
|
||||
const tableRef = useRef(null);
|
||||
const tableContainerRef = useRef(null);
|
||||
const [gridWidth, setGridWidth] = useState(0);
|
||||
const [updateFlex, setUpdateFlex] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = tableContainerRef.current;
|
||||
if (!node || typeof ResizeObserver === 'undefined') return;
|
||||
const update = () => setGridWidth(node.clientWidth);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(node);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const lastDetailRef = useRef(null);
|
||||
|
||||
const reorderTasks = useCallback(
|
||||
(detail) => {
|
||||
const id = detail.id;
|
||||
const { before, after } = detail;
|
||||
const inProgress = detail.onMove;
|
||||
|
||||
let target = before || after;
|
||||
let mode = before ? 'before' : 'after';
|
||||
|
||||
if (inProgress) {
|
||||
if (mode === 'after') {
|
||||
const task = api.getTask(target);
|
||||
if (task.data?.length && task.open) {
|
||||
mode = 'before';
|
||||
target = task.data[0].id;
|
||||
}
|
||||
}
|
||||
lastDetailRef.current = { id, [mode]: target };
|
||||
} else lastDetailRef.current = null;
|
||||
|
||||
api.exec('move-task', {
|
||||
id,
|
||||
mode,
|
||||
target,
|
||||
inProgress,
|
||||
});
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const scrollDelta = useMemo(() => areaVal?.from ?? 0, [areaVal]);
|
||||
const headerHeight = useMemo(() => scalesVal?.height ?? 0, [scalesVal]);
|
||||
|
||||
const scrollX = useMemo(() => {
|
||||
if (!compactMode && display !== 'grid') {
|
||||
return (columnWidth ?? 0) > (width ?? 0);
|
||||
} else {
|
||||
return (columnWidth ?? 0) > (gridWidth ?? 0);
|
||||
}
|
||||
}, [compactMode, display, columnWidth, width, gridWidth]);
|
||||
|
||||
const tableStyle = useMemo(() => {
|
||||
const style = {};
|
||||
if ((scrollX && display === 'all') || (display === 'grid' && scrollX)) {
|
||||
style.width = columnWidth;
|
||||
} else if (display === 'grid') {
|
||||
style.width = '100%';
|
||||
}
|
||||
return style;
|
||||
}, [scrollX, display, columnWidth]);
|
||||
|
||||
const allTasks = useMemo(() => {
|
||||
if (dragTask && !tasks.find((t) => t.id === dragTask.id)) {
|
||||
return [...tasks, dragTask];
|
||||
}
|
||||
return tasks;
|
||||
}, [tasks, dragTask]);
|
||||
|
||||
const cols = useMemo(() => {
|
||||
let cols = (columnsVal || []).map((col) => {
|
||||
col = { ...col };
|
||||
const header = col.header;
|
||||
if (typeof header === 'object') {
|
||||
const text = header.text && _(header.text);
|
||||
col.header = { ...header, text };
|
||||
} else col.header = _(header);
|
||||
return col;
|
||||
});
|
||||
const ti = cols.findIndex((c) => c.id === 'text');
|
||||
const ai = cols.findIndex((c) => c.id === 'add-task');
|
||||
|
||||
if (ti !== -1) {
|
||||
if (cols[ti].cell) cols[ti]._cell = cols[ti].cell;
|
||||
cols[ti].cell = TextCell;
|
||||
}
|
||||
if (ai !== -1) {
|
||||
cols[ai].cell = cols[ai].cell || ActionCell;
|
||||
|
||||
const header = cols[ai].header;
|
||||
if (typeof header !== 'object') cols[ai].header = { text: header };
|
||||
cols[ai].header.cell = header.cell || ActionCell;
|
||||
|
||||
if (readonly) {
|
||||
cols.splice(ai, 1);
|
||||
} else {
|
||||
if (compactMode) {
|
||||
const [actionCol] = cols.splice(ai, 1);
|
||||
cols.unshift(actionCol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cols.length > 0) cols[cols.length - 1].resize = false;
|
||||
return cols;
|
||||
}, [columnsVal, _, readonly, compactMode]);
|
||||
|
||||
const basis = useMemo(() => {
|
||||
if (display === 'all') return `${width}px`;
|
||||
if (display === 'grid') return 'calc(100% - 4px)';
|
||||
return cols.find((c) => c.id === 'add-task') ? '50px' : '0';
|
||||
}, [display, width, cols]);
|
||||
|
||||
const sortMarks = useMemo(() => {
|
||||
if (allTasks && sortVal?.length) {
|
||||
const marks = {};
|
||||
sortVal.forEach(({ key, order }, index) => {
|
||||
marks[key] = {
|
||||
order,
|
||||
...(sortVal.length > 1 && { index }),
|
||||
};
|
||||
});
|
||||
return marks;
|
||||
}
|
||||
return {};
|
||||
}, [allTasks, sortVal]);
|
||||
|
||||
|
||||
const checkFlex = useCallback(() => {
|
||||
return cols.some((c) => c.flexgrow && !c.hidden);
|
||||
}, []); // cols defined later; will use latest value when invoked
|
||||
|
||||
const hasFlexCol = useMemo(() => {
|
||||
// updateFlex is used to trigger re-evaluation
|
||||
void updateFlex;
|
||||
return checkFlex();
|
||||
}, [checkFlex, updateFlex]);
|
||||
|
||||
const fitColumns = useMemo(() => {
|
||||
let filteredColumns =
|
||||
display === 'chart' ? cols.filter((c) => c.id === 'add-task') : cols;
|
||||
|
||||
const containerWidth = display === 'all' ? width : gridWidth;
|
||||
if (!hasFlexCol) {
|
||||
let baseColumnWidth = columnWidth;
|
||||
let forceReset = false;
|
||||
if (cols.some((c) => c.$width)) {
|
||||
let actualWidth = 0;
|
||||
baseColumnWidth = cols.reduce((acc, col) => {
|
||||
if (!col.hidden) {
|
||||
actualWidth += col.width;
|
||||
acc += col.$width || col.width;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
if (actualWidth > baseColumnWidth && baseColumnWidth > containerWidth)
|
||||
forceReset = true;
|
||||
}
|
||||
|
||||
if (forceReset || baseColumnWidth < containerWidth) {
|
||||
let k = 1;
|
||||
if (!forceReset)
|
||||
k = (containerWidth - 50) / (baseColumnWidth - 50 || 1);
|
||||
return filteredColumns.map((c) => {
|
||||
if (c.id !== 'add-task' && !c.hidden) {
|
||||
if (!c.$width) c.$width = c.width;
|
||||
c.width = c.$width * k;
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}
|
||||
}
|
||||
return filteredColumns;
|
||||
}, [display, cols, hasFlexCol, columnWidth, width, gridWidth]);
|
||||
|
||||
|
||||
const setColumnWidth = useCallback(
|
||||
(resized) => {
|
||||
if (!checkFlex()) {
|
||||
const newColumnWidth = fitColumns.reduce((acc, col) => {
|
||||
if (resized && col.$width) col.$width = col.width;
|
||||
return acc + (col.hidden ? 0 : col.width);
|
||||
}, 0);
|
||||
if (newColumnWidth !== columnWidth) setColumnWidthProp(newColumnWidth);
|
||||
}
|
||||
setUpdateFlex(true);
|
||||
setUpdateFlex(false);
|
||||
},
|
||||
[checkFlex, fitColumns, columnWidth, setColumnWidthProp],
|
||||
);
|
||||
|
||||
const adjustColumns = useCallback(() => {
|
||||
const flexCols = cols.filter((c) => c.flexgrow && !c.hidden);
|
||||
if (flexCols.length === 1)
|
||||
cols.forEach((c) => {
|
||||
if (c.$width && !c.flexgrow && !c.hidden) c.width = c.$width;
|
||||
});
|
||||
}, []); // cols defined later; will use latest
|
||||
|
||||
const onDblClick = useCallback(
|
||||
(e) => {
|
||||
if (!readonly) {
|
||||
const id = locateID(e);
|
||||
const column = locateAttr(e, 'data-col-id');
|
||||
const columnObj = column && cols.find((c) => c.id == column);
|
||||
if (!columnObj?.editor && id) api.exec('show-editor', { id });
|
||||
}
|
||||
},
|
||||
[api, readonly], // cols is defined later; relies on latest value at call time
|
||||
);
|
||||
|
||||
const endScroll = useCallback(() => {
|
||||
scrollRef.current = false;
|
||||
}, []);
|
||||
|
||||
const onTouchstart = useCallback(
|
||||
(e) => {
|
||||
scrollRef.current = true;
|
||||
touchYRef.current = e.touches[0].clientY + (scrollTopVal ?? 0);
|
||||
},
|
||||
[scrollTopVal],
|
||||
);
|
||||
|
||||
const onTouchmove = useCallback(
|
||||
(e) => {
|
||||
if (scrollRef.current) {
|
||||
const delta = (touchYRef.current ?? 0) - e.touches[0].clientY;
|
||||
api.exec('scroll-chart', { top: delta });
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const sel = useMemo(
|
||||
() => (Array.isArray(selectedVal) ? selectedVal.map((o) => o.id) : []),
|
||||
[selectedVal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => endScroll();
|
||||
window.addEventListener('touchend', handler);
|
||||
return () => window.removeEventListener('touchend', handler);
|
||||
}, [endScroll]);
|
||||
|
||||
const setScrollOffset = useCallback(() => {
|
||||
if (tableRef.current && allTasks !== null) {
|
||||
const body = tableRef.current.querySelector('.wx-body');
|
||||
if (body)
|
||||
body.style.top = -((scrollTopVal ?? 0) - (scrollDelta ?? 0)) + 'px';
|
||||
}
|
||||
if (tableContainerRef.current) tableContainerRef.current.scrollTop = 0;
|
||||
}, [allTasks, scrollTopVal, scrollDelta]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableRef.current) {
|
||||
setScrollOffset();
|
||||
}
|
||||
}, [scrollTopVal, scrollDelta, setScrollOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
const tableEl = tableRef.current;
|
||||
if (!tableEl) return;
|
||||
const bodyEl = tableEl.querySelector('.wx-table-box .wx-body');
|
||||
if (!bodyEl || typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setScrollOffset();
|
||||
});
|
||||
ro.observe(bodyEl);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [fitColumns, tableStyle, display, basis, allTasks, setScrollOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollTask || !tableAPI) return;
|
||||
|
||||
const { id } = scrollTask;
|
||||
const focusCell = tableAPI.getState().focusCell;
|
||||
if (
|
||||
focusCell &&
|
||||
focusCell.row !== id &&
|
||||
tableRef.current &&
|
||||
tableRef.current.contains(document.activeElement)
|
||||
) {
|
||||
tableAPI.exec('focus-cell', {
|
||||
row: id,
|
||||
column: focusCell.column,
|
||||
});
|
||||
}
|
||||
|
||||
}, [scrollTask, tableAPI]);
|
||||
|
||||
const startReorder = useCallback(
|
||||
({ id }) => {
|
||||
if (readonly) return false;
|
||||
|
||||
if (api.getTask(id).open) api.exec('open-task', { id, mode: false });
|
||||
|
||||
const t = api.getState()._tasks.find((t) => t.id === id);
|
||||
setDragTask(t || null);
|
||||
if (!t) return false;
|
||||
},
|
||||
[api, readonly],
|
||||
);
|
||||
|
||||
const endReorder = useCallback(
|
||||
({ id, top }) => {
|
||||
if (lastDetailRef.current) {
|
||||
reorderTasks({ ...lastDetailRef.current, onMove: false });
|
||||
} else {
|
||||
api.exec('drag-task', {
|
||||
id,
|
||||
top: top + (scrollDelta ?? 0),
|
||||
inProgress: false,
|
||||
});
|
||||
}
|
||||
setDragTask(null);
|
||||
},
|
||||
[api, reorderTasks, scrollDelta],
|
||||
);
|
||||
|
||||
const moveReorder = useCallback(
|
||||
({ id, top, detail }) => {
|
||||
if (detail) {
|
||||
reorderTasks({ ...detail, onMove: true });
|
||||
}
|
||||
api.exec('drag-task', {
|
||||
id,
|
||||
top: top + (scrollDelta ?? 0),
|
||||
inProgress: true,
|
||||
});
|
||||
},
|
||||
[api, reorderTasks, scrollDelta],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const node = tableRef.current;
|
||||
if (!node) return;
|
||||
const action = reorder(node, {
|
||||
start: startReorder,
|
||||
touchStart: endScroll,
|
||||
end: endReorder,
|
||||
move: moveReorder,
|
||||
getTask: api.getTask,
|
||||
});
|
||||
return action.destroy;
|
||||
}, [api, startReorder, endScroll, endReorder, moveReorder]);
|
||||
|
||||
const handleHotkey = useCallback(
|
||||
(ev) => {
|
||||
const { key, isInput } = ev;
|
||||
if (!isInput && (key === 'arrowup' || key === 'arrowdown')) {
|
||||
ev.eventSource = 'grid';
|
||||
api.exec('hotkey', ev);
|
||||
return false;
|
||||
} else if (key === 'enter') {
|
||||
const focusCell = tableAPI?.getState().focusCell;
|
||||
if (focusCell) {
|
||||
const { row, column } = focusCell;
|
||||
if (column === 'add-task') {
|
||||
execAction(row, 'add-task');
|
||||
} else if (column === 'text') {
|
||||
execAction(row, 'open-task');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[api, execAction, tableAPI],
|
||||
);
|
||||
|
||||
// FIXME - temporary hack to provide fresh values to grid's handlers
|
||||
const handlersStateRef = useRef(null);
|
||||
const setHandlersState = () => {
|
||||
handlersStateRef.current = {
|
||||
setTableAPI,
|
||||
handleHotkey,
|
||||
sortVal,
|
||||
api,
|
||||
adjustColumns,
|
||||
setColumnWidth,
|
||||
tasks,
|
||||
durationUnitVal,
|
||||
onTableAPIChange
|
||||
};
|
||||
};
|
||||
setHandlersState();
|
||||
useEffect(() => {
|
||||
setHandlersState();
|
||||
}, [setTableAPI, handleHotkey, sortVal, api, adjustColumns, setColumnWidth, tasks, durationUnitVal, onTableAPIChange]);
|
||||
|
||||
const init = useCallback(
|
||||
(tapi) => {
|
||||
setTableAPI(tapi);
|
||||
tapi.intercept('hotkey', (ev) => handlersStateRef.current.handleHotkey(ev));
|
||||
tapi.intercept('scroll', () => false);
|
||||
tapi.intercept('select-row', () => false);
|
||||
tapi.intercept('sort-rows', (e) => {
|
||||
const sortVal = handlersStateRef.current.sortVal;
|
||||
const { key, add } = e;
|
||||
const keySort = sortVal ? sortVal.find((s) => s.key === key) : null;
|
||||
let order = 'asc';
|
||||
if (keySort)
|
||||
order = !keySort || keySort.order === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
api.exec('sort-tasks', {
|
||||
key,
|
||||
order,
|
||||
add,
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
tapi.on('resize-column', () => {
|
||||
handlersStateRef.current.setColumnWidth(true);
|
||||
});
|
||||
|
||||
tapi.on('hide-column', (ev) => {
|
||||
if (!ev.mode) handlersStateRef.current.adjustColumns();
|
||||
handlersStateRef.current.setColumnWidth();
|
||||
});
|
||||
|
||||
tapi.intercept('update-cell', (e) => {
|
||||
const { id, column, value } = e;
|
||||
const task = handlersStateRef.current.tasks.find((t) => t.id === id);
|
||||
|
||||
if (task) {
|
||||
const update = { ...task };
|
||||
let v = value;
|
||||
if (v && !isNaN(v) && !(v instanceof Date)) v *= 1;
|
||||
update[column] = v;
|
||||
|
||||
normalizeDates(update, handlersStateRef.current.durationUnitVal, true, column);
|
||||
|
||||
api.exec('update-task', {
|
||||
id: id,
|
||||
task: update,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
onTableAPIChange && onTableAPIChange(tapi);
|
||||
}, [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="wx-rHj6070p wx-table-container"
|
||||
style={{ flex: `0 0 ${basis}` }}
|
||||
ref={tableContainerRef}
|
||||
>
|
||||
<div
|
||||
ref={tableRef}
|
||||
style={tableStyle}
|
||||
className="wx-rHj6070p wx-table"
|
||||
onTouchStart={onTouchstart}
|
||||
onTouchMove={onTouchmove}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDblClick}
|
||||
>
|
||||
<WxGrid
|
||||
init={init}
|
||||
sizes={{
|
||||
rowHeight: cellHeightVal,
|
||||
headerHeight: (headerHeight ?? 0) - 1,
|
||||
}}
|
||||
rowStyle={(row) =>
|
||||
row.$reorder ? 'wx-rHj6070p wx-reorder-task' : 'wx-rHj6070p'
|
||||
}
|
||||
columnStyle={(col) =>
|
||||
`wx-rHj6070p wx-text-${col.align}${col.id === 'add-task' ? ' wx-action' : ''}`
|
||||
}
|
||||
data={allTasks}
|
||||
columns={fitColumns}
|
||||
selectedRows={[...sel]}
|
||||
sortMarks={sortMarks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/grid/HeaderMenu.jsx
Normal file
18
src/components/grid/HeaderMenu.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { HeaderMenu as HeaderMenuInner } from '@svar-ui/react-grid';
|
||||
|
||||
const HeaderMenu = ({ children, columns = null, api }) => {
|
||||
const [tableAPI, setTableAPI] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
api.getTable(true).then(setTableAPI);
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<HeaderMenuInner api={tableAPI} columns={columns}>
|
||||
{children}
|
||||
</HeaderMenuInner>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderMenu;
|
||||
30
src/components/grid/TextCell.css
Normal file
30
src/components/grid/TextCell.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.wx-content.wx-pqc08MHU {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wx-toggle-icon.wx-pqc08MHU {
|
||||
width: var(--wx-icon-size);
|
||||
min-width: 12px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
margin: 0 5px;
|
||||
font-size: var(--wx-icon-size);
|
||||
color: var(--wx-gantt-icon-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wx-toggle-placeholder.wx-pqc08MHU {
|
||||
width: var(--wx-icon-size);
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
margin: 0 5px;
|
||||
flex: 0 0 var(--wx-icon-size);
|
||||
}
|
||||
|
||||
.wx-text.wx-pqc08MHU {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
30
src/components/grid/TextCell.jsx
Normal file
30
src/components/grid/TextCell.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import './TextCell.css';
|
||||
|
||||
function TextCell({ row, column }) {
|
||||
function getStyle(row, col) {
|
||||
return {
|
||||
justifyContent: col.align,
|
||||
paddingLeft: `${(row.$level - 1) * 20}px`,
|
||||
};
|
||||
}
|
||||
|
||||
const CellComponent = column && column._cell;
|
||||
|
||||
return (
|
||||
<div className="wx-pqc08MHU wx-content" style={getStyle(row, column)}>
|
||||
{row.data || row.lazy ? (
|
||||
<i
|
||||
className={`wx-pqc08MHU wx-toggle-icon wxi-menu-${row.open ? 'down' : 'right'}`}
|
||||
data-action="open-task"
|
||||
/>
|
||||
) : (
|
||||
<i className="wx-pqc08MHU wx-toggle-placeholder" />
|
||||
)}
|
||||
<div className="wx-pqc08MHU wx-text">
|
||||
{CellComponent ? <CellComponent row={row} column={column} /> : row.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextCell;
|
||||
4
src/context.js
Normal file
4
src/context.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
const store = createContext(null);
|
||||
export default store;
|
||||
14
src/full-css.js
Normal file
14
src/full-css.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Entry point for full CSS build that includes both component and base styles
|
||||
|
||||
// Import base component styles
|
||||
import '@svar-ui/react-core/style.css';
|
||||
import '@svar-ui/react-grid/style.css';
|
||||
import '@svar-ui/react-editor/style.css';
|
||||
import '@svar-ui/react-menu/style.css';
|
||||
import '@svar-ui/react-toolbar/style.css';
|
||||
import '@svar-ui/react-comments/style.css';
|
||||
import '@svar-ui/react-tasklist/style.css';
|
||||
|
||||
// Import component styles
|
||||
import * as data from './index.js';
|
||||
export default data;
|
||||
73
src/helpers/hotkey.js
Normal file
73
src/helpers/hotkey.js
Normal file
@@ -0,0 +1,73 @@
|
||||
class ScreenKeys {
|
||||
constructor() {
|
||||
this.store = new Map();
|
||||
}
|
||||
limit(check) {
|
||||
this._scope = check;
|
||||
}
|
||||
isActive() {
|
||||
return !this._scope || this._scope();
|
||||
}
|
||||
add(key, handler) {
|
||||
this.store.set(key.toLowerCase().replace(/[ ]/g, ''), handler);
|
||||
}
|
||||
}
|
||||
|
||||
const chain = [];
|
||||
export const hotkeys = {
|
||||
subscribe: (v) => {
|
||||
init_once();
|
||||
|
||||
const t = new ScreenKeys();
|
||||
chain.push(t);
|
||||
v(t);
|
||||
|
||||
return () => {
|
||||
const ind = chain.findIndex((a) => a === t);
|
||||
if (ind >= 0) chain.splice(ind, 1);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
var ready = false;
|
||||
function init_once() {
|
||||
if (ready) return;
|
||||
ready = true;
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (
|
||||
chain.length &&
|
||||
(ev.ctrlKey ||
|
||||
ev.altKey ||
|
||||
ev.metaKey ||
|
||||
ev.shiftKey ||
|
||||
ev.key.length > 1 ||
|
||||
ev.key === ' ')
|
||||
) {
|
||||
const code = [];
|
||||
if (ev.ctrlKey) code.push('ctrl');
|
||||
if (ev.altKey) code.push('alt');
|
||||
if (ev.metaKey) code.push('meta');
|
||||
if (ev.shiftKey) code.push('shift');
|
||||
let evKey = ev.key.toLocaleLowerCase();
|
||||
if (ev.key === ' ') {
|
||||
evKey = 'space';
|
||||
}
|
||||
code.push(evKey);
|
||||
|
||||
const key = code.join('+');
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
const t = chain[i];
|
||||
const h = t.store.get(key) || t.store.get(evKey);
|
||||
const tagName = ev.target.tagName;
|
||||
if (h && tagName !== 'INPUT' && tagName !== 'TEXTAREA') {
|
||||
if (t.isActive()) {
|
||||
h(ev);
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
5
src/helpers/locate.js
Normal file
5
src/helpers/locate.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function getID(node) {
|
||||
const id = node.getAttribute('data-id');
|
||||
const numId = parseInt(id);
|
||||
return isNaN(numId) || numId.toString() != id ? id : numId;
|
||||
}
|
||||
30
src/helpers/modeResizeObserver.js
Normal file
30
src/helpers/modeResizeObserver.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const COMPACT_WIDTH = 650;
|
||||
|
||||
export function modeObserver(callback) {
|
||||
let observer;
|
||||
|
||||
function observe() {
|
||||
observer = new ResizeObserver((entries) => {
|
||||
for (let obj of entries) {
|
||||
if (obj.target === document.body) {
|
||||
let mode = obj.contentRect.width <= COMPACT_WIDTH;
|
||||
callback(mode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body);
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
observe,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
207
src/helpers/reorder.js
Normal file
207
src/helpers/reorder.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import { getID } from './locate';
|
||||
import { locate } from '@svar-ui/lib-dom';
|
||||
|
||||
function getOffset(node, relative, ev) {
|
||||
const box = node.getBoundingClientRect();
|
||||
const base = relative.querySelector('.wx-body').getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: box.top - base.top,
|
||||
left: box.left - base.left,
|
||||
dt: box.bottom - ev.clientY,
|
||||
db: ev.clientY - box.top,
|
||||
};
|
||||
}
|
||||
|
||||
function checkSource(node) {
|
||||
return node && node.getAttribute('data-context-id');
|
||||
}
|
||||
|
||||
const SHIFT = 5;
|
||||
|
||||
export function reorder(node, config) {
|
||||
let source, clone, sid;
|
||||
let x, y, base, detail;
|
||||
let touched, touchTimer;
|
||||
|
||||
function down(event) {
|
||||
x = event.clientX;
|
||||
y = event.clientY;
|
||||
base = {
|
||||
...getOffset(source, node, event),
|
||||
y: config.getTask(sid).$y,
|
||||
};
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
function handleTouchstart(event) {
|
||||
source = locate(event);
|
||||
if (!checkSource(source)) return;
|
||||
|
||||
sid = getID(source);
|
||||
|
||||
touchTimer = setTimeout(() => {
|
||||
touched = true;
|
||||
if (config && config.touchStart) config.touchStart();
|
||||
down(event.touches[0]);
|
||||
}, 500);
|
||||
|
||||
node.addEventListener('touchmove', handleTouchmove);
|
||||
node.addEventListener('contextmenu', handleContext);
|
||||
window.addEventListener('touchend', handleTouchend);
|
||||
}
|
||||
|
||||
function handleContext(event) {
|
||||
if (touched || touchTimer) {
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMousedown(event) {
|
||||
if (event.which !== 1) return;
|
||||
|
||||
source = locate(event);
|
||||
if (!checkSource(source)) return;
|
||||
|
||||
sid = getID(source);
|
||||
|
||||
node.addEventListener('mousemove', handleMousemove);
|
||||
window.addEventListener('mouseup', handleMouseup);
|
||||
|
||||
down(event);
|
||||
}
|
||||
|
||||
function end(full) {
|
||||
node.removeEventListener('mousemove', handleMousemove);
|
||||
node.removeEventListener('touchmove', handleTouchmove);
|
||||
document.body.removeEventListener('mouseup', handleMouseup);
|
||||
document.body.removeEventListener('touchend', handleTouchend);
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
if (full) {
|
||||
node.removeEventListener('mousedown', handleMousedown);
|
||||
node.removeEventListener('touchstart', handleTouchstart);
|
||||
}
|
||||
}
|
||||
|
||||
function move(event) {
|
||||
const dx = event.clientX - x;
|
||||
const dy = event.clientY - y;
|
||||
if (!clone) {
|
||||
if (Math.abs(dx) < SHIFT && Math.abs(dy) < SHIFT) return;
|
||||
if (config && config.start) {
|
||||
if (config.start({ id: sid, e: event }) === false) return;
|
||||
}
|
||||
|
||||
clone = source.cloneNode(true);
|
||||
clone.style.pointerEvents = 'none';
|
||||
clone.classList.add('wx-reorder-task');
|
||||
clone.style.position = 'absolute';
|
||||
clone.style.left = base.left + 'px';
|
||||
clone.style.top = base.top + 'px';
|
||||
|
||||
source.style.visibility = 'hidden';
|
||||
source.parentNode.insertBefore(clone, source);
|
||||
}
|
||||
|
||||
if (clone) {
|
||||
const top = Math.round(Math.max(0, base.top + dy));
|
||||
|
||||
if (config && config.move) {
|
||||
if (config.move({ id: sid, top, detail }) === false) return;
|
||||
}
|
||||
|
||||
const task = config.getTask(sid);
|
||||
const y = task.$y;
|
||||
//dnd may be blocked
|
||||
if (!base.start && base.y == y) return up();
|
||||
|
||||
base.start = true;
|
||||
base.y = task.$y - 4;
|
||||
clone.style.top = top + 'px'; //task.$y - scroll
|
||||
|
||||
const targetNode = document.elementFromPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
const target = locate(targetNode);
|
||||
|
||||
if (target && target !== source) {
|
||||
const tid = getID(target);
|
||||
const box = target.getBoundingClientRect();
|
||||
const line = box.top + box.height / 2;
|
||||
|
||||
const after =
|
||||
event.clientY + base.db > line &&
|
||||
target.nextElementSibling !== source;
|
||||
const before =
|
||||
event.clientY - base.dt < line &&
|
||||
target.previousElementSibling !== source;
|
||||
|
||||
if (detail?.after == tid || detail?.before == tid) {
|
||||
// avoid duplicate calls
|
||||
detail = null;
|
||||
} else if (after) {
|
||||
// move down
|
||||
detail = { id: sid, after: tid };
|
||||
} else if (before) {
|
||||
// move up
|
||||
detail = { id: sid, before: tid };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMousemove(event) {
|
||||
move(event);
|
||||
}
|
||||
|
||||
function handleTouchmove(event) {
|
||||
if (touched) {
|
||||
event.preventDefault();
|
||||
move(event.touches[0]);
|
||||
} else if (touchTimer) {
|
||||
clearTimeout(touchTimer);
|
||||
touchTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchend() {
|
||||
touched = null;
|
||||
if (touchTimer) {
|
||||
clearTimeout(touchTimer);
|
||||
touchTimer = null;
|
||||
}
|
||||
up();
|
||||
}
|
||||
|
||||
function handleMouseup() {
|
||||
up();
|
||||
}
|
||||
|
||||
function up() {
|
||||
if (source) {
|
||||
source.style.visibility = '';
|
||||
}
|
||||
if (clone) {
|
||||
clone.parentNode.removeChild(clone);
|
||||
if (config && config.end) config.end({ id: sid, top: base.top });
|
||||
}
|
||||
|
||||
sid = source = clone = base = detail = null;
|
||||
end();
|
||||
}
|
||||
|
||||
if (node.style.position !== 'absolute') node.style.position = 'relative';
|
||||
|
||||
node.addEventListener('mousedown', handleMousedown);
|
||||
node.addEventListener('touchstart', handleTouchstart);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
end(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
36
src/index.js
Normal file
36
src/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import Gantt from './components/Gantt.jsx';
|
||||
import Fullscreen from './components/Fullscreen.jsx';
|
||||
import Toolbar from './components/Toolbar.jsx';
|
||||
import ContextMenu from './components/ContextMenu.jsx';
|
||||
import Editor from './components/Editor.jsx';
|
||||
import HeaderMenu from './components/grid/HeaderMenu.jsx';
|
||||
|
||||
import Tooltip from './widgets/Tooltip.jsx';
|
||||
|
||||
import Material from './themes/Material.jsx';
|
||||
import Willow from './themes/Willow.jsx';
|
||||
import WillowDark from './themes/WillowDark.jsx';
|
||||
|
||||
export {
|
||||
defaultEditorItems,
|
||||
defaultToolbarButtons,
|
||||
defaultMenuOptions,
|
||||
defaultColumns,
|
||||
defaultTaskTypes,
|
||||
registerScaleUnit,
|
||||
} from '@svar-ui/gantt-store';
|
||||
|
||||
export { registerEditorItem } from '@svar-ui/react-editor';
|
||||
|
||||
export {
|
||||
Gantt,
|
||||
Fullscreen,
|
||||
ContextMenu,
|
||||
HeaderMenu,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Editor,
|
||||
Material,
|
||||
Willow,
|
||||
WillowDark,
|
||||
};
|
||||
155
src/themes/Material.css
Normal file
155
src/themes/Material.css
Normal file
@@ -0,0 +1,155 @@
|
||||
.wx-QSwitwNQ {
|
||||
--wx-gantt-border-color: #e6e6e6;
|
||||
--wx-gantt-border: var(--wx-border);
|
||||
--wx-gantt-form-header-border: var(--wx-border);
|
||||
--wx-gantt-icon-color: var(--wx-icon-color);
|
||||
|
||||
/* bars */
|
||||
--wx-gantt-bar-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-gantt-bar-border-radius: 50px;
|
||||
--wx-gantt-milestone-border-radius: 3px;
|
||||
|
||||
--wx-gantt-task-color: #448aff;
|
||||
--wx-gantt-task-font-color: #fff;
|
||||
--wx-gantt-task-fill-color: #246cd9;
|
||||
--wx-gantt-task-border-color: #448aff;
|
||||
--wx-gantt-task-border: 1px solid #246cd9;
|
||||
|
||||
--wx-gantt-summary-color: #1de9b6;
|
||||
--wx-gantt-summary-font-color: #5f5f5f;
|
||||
--wx-gantt-summary-fill-color: #00d19a;
|
||||
--wx-gantt-summary-border-color: #1de9b6;
|
||||
--wx-gantt-summary-border: 1px solid #00d19a;
|
||||
|
||||
--wx-gantt-milestone-color: #d33daf;
|
||||
|
||||
--wx-gantt-select-color: rgb(201, 244, 240);
|
||||
--wx-gantt-link-color: #87a4bc;
|
||||
--wx-gantt-link-marker-background: #f0f0f0;
|
||||
--wx-gantt-link-marker-color: #87a4bc;
|
||||
|
||||
--wx-gantt-bar-shadow: 0px 1px 2px rgba(44, 47, 60, 0.06),
|
||||
0px 3px 10px rgba(44, 47, 60, 0.12);
|
||||
|
||||
--wx-gantt-progress-marker-height: 22px;
|
||||
--wx-gantt-progress-border-color: #dfdfdf;
|
||||
|
||||
--wx-gantt-baseline-border-radius: 4px;
|
||||
|
||||
/* grid */
|
||||
--wx-grid-header-font: 500 14px Roboto;
|
||||
--wx-grid-header-font-color: #a6a6a6;
|
||||
--wx-grid-header-text-transform: uppercase;
|
||||
--wx-grid-header-shadow: 0px 3px 5px rgba(0, 0, 0, 0.1),
|
||||
0px 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--wx-grid-body-font: 400 14px Roboto;
|
||||
--wx-grid-body-font-color: #5f5f5f;
|
||||
--wx-grid-body-row-border: 1px solid transparent;
|
||||
--wx-grid-body-cell-border: 1px solid transparent;
|
||||
|
||||
/* timescale */
|
||||
--wx-timescale-font: 500 12px Roboto;
|
||||
--wx-timescale-font-color: #a6a6a6;
|
||||
--wx-timescale-text-transform: uppercase;
|
||||
--wx-timescale-shadow: 0px 3px 5px rgba(0, 0, 0, 0.1),
|
||||
0px 1px 2px rgba(0, 0, 0, 0.1);
|
||||
--wx-timescale-border: 1px solid transparent;
|
||||
|
||||
/* grid and timescale */
|
||||
--wx-gantt-holiday-background: #f3f7fc;
|
||||
--wx-gantt-holiday-color: #9fa1ae;
|
||||
|
||||
/* markers */
|
||||
--wx-gantt-marker-font: 500 12px Roboto;
|
||||
--wx-gantt-marker-font-color: #fff;
|
||||
--wx-gantt-marker-color: rgba(6, 189, 248, 0.77);
|
||||
|
||||
/* tooltips */
|
||||
--wx-tooltip-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-tooltip-font-color: #e6e6e6;
|
||||
--wx-tooltip-background: rgba(0, 0, 0, 0.7);
|
||||
|
||||
/* sidebar */
|
||||
--wx-sidebar-close-icon: var(--wx-color-secondary-font);
|
||||
}
|
||||
|
||||
.wx-material-theme {
|
||||
--wx-gantt-border-color: #e6e6e6;
|
||||
--wx-gantt-border: var(--wx-border);
|
||||
--wx-gantt-form-header-border: var(--wx-border);
|
||||
--wx-gantt-icon-color: var(--wx-icon-color);
|
||||
|
||||
/* bars */
|
||||
--wx-gantt-bar-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-gantt-bar-border-radius: 50px;
|
||||
--wx-gantt-milestone-border-radius: 3px;
|
||||
|
||||
--wx-gantt-task-color: #448aff;
|
||||
--wx-gantt-task-font-color: #fff;
|
||||
--wx-gantt-task-fill-color: #246cd9;
|
||||
--wx-gantt-task-border-color: #448aff;
|
||||
--wx-gantt-task-border: 1px solid #246cd9;
|
||||
|
||||
--wx-gantt-summary-color: #1de9b6;
|
||||
--wx-gantt-summary-font-color: #5f5f5f;
|
||||
--wx-gantt-summary-fill-color: #00d19a;
|
||||
--wx-gantt-summary-border-color: #1de9b6;
|
||||
--wx-gantt-summary-border: 1px solid #00d19a;
|
||||
|
||||
--wx-gantt-milestone-color: #d33daf;
|
||||
|
||||
--wx-gantt-select-color: rgb(201, 244, 240);
|
||||
--wx-gantt-link-color: #87a4bc;
|
||||
--wx-gantt-link-marker-background: #f0f0f0;
|
||||
--wx-gantt-link-marker-color: #87a4bc;
|
||||
|
||||
--wx-gantt-bar-shadow: 0px 1px 2px rgba(44, 47, 60, 0.06),
|
||||
0px 3px 10px rgba(44, 47, 60, 0.12);
|
||||
|
||||
--wx-gantt-progress-marker-height: 22px;
|
||||
--wx-gantt-progress-border-color: #dfdfdf;
|
||||
|
||||
--wx-gantt-baseline-border-radius: 4px;
|
||||
|
||||
/* grid */
|
||||
--wx-grid-header-font: 500 14px Roboto;
|
||||
--wx-grid-header-font-color: #a6a6a6;
|
||||
--wx-grid-header-text-transform: uppercase;
|
||||
--wx-grid-header-shadow: 0px 3px 5px rgba(0, 0, 0, 0.1),
|
||||
0px 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--wx-grid-body-font: 400 14px Roboto;
|
||||
--wx-grid-body-font-color: #5f5f5f;
|
||||
--wx-grid-body-row-border: 1px solid transparent;
|
||||
--wx-grid-body-cell-border: 1px solid transparent;
|
||||
|
||||
/* timescale */
|
||||
--wx-timescale-font: 500 12px Roboto;
|
||||
--wx-timescale-font-color: #a6a6a6;
|
||||
--wx-timescale-text-transform: uppercase;
|
||||
--wx-timescale-shadow: 0px 3px 5px rgba(0, 0, 0, 0.1),
|
||||
0px 1px 2px rgba(0, 0, 0, 0.1);
|
||||
--wx-timescale-border: 1px solid transparent;
|
||||
|
||||
/* grid and timescale */
|
||||
--wx-gantt-holiday-background: #f3f7fc;
|
||||
--wx-gantt-holiday-color: #9fa1ae;
|
||||
|
||||
/* markers */
|
||||
--wx-gantt-marker-font: 500 12px Roboto;
|
||||
--wx-gantt-marker-font-color: #fff;
|
||||
--wx-gantt-marker-color: rgba(6, 189, 248, 0.77);
|
||||
|
||||
/* tooltips */
|
||||
--wx-tooltip-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-tooltip-font-color: #e6e6e6;
|
||||
--wx-tooltip-background: rgba(0, 0, 0, 0.7);
|
||||
|
||||
/* sidebar */
|
||||
--wx-sidebar-close-icon: var(--wx-color-secondary-font);
|
||||
}
|
||||
12
src/themes/Material.jsx
Normal file
12
src/themes/Material.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Material as CoreMaterial } from '@svar-ui/react-core';
|
||||
import './Material.css';
|
||||
|
||||
function Material({ fonts = true, children }) {
|
||||
if (children) {
|
||||
return <CoreMaterial fonts={fonts}>{children()}</CoreMaterial>;
|
||||
} else {
|
||||
return <CoreMaterial fonts={fonts} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default Material;
|
||||
79
src/themes/Willow.css
Normal file
79
src/themes/Willow.css
Normal file
@@ -0,0 +1,79 @@
|
||||
.wx-willow-theme {
|
||||
--wx-gantt-border-color: #e6e6e6;
|
||||
--wx-gantt-border: 1px solid #1d1e261a;
|
||||
--wx-gantt-form-header-border: none;
|
||||
--wx-gantt-icon-color: #9fa1ae;
|
||||
|
||||
/* bars */
|
||||
--wx-gantt-bar-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-gantt-bar-border-radius: 3px;
|
||||
--wx-gantt-milestone-border-radius: 3px;
|
||||
|
||||
--wx-gantt-task-color: #3983eb;
|
||||
--wx-gantt-task-font-color: #fff;
|
||||
--wx-gantt-task-fill-color: #1f6bd9;
|
||||
--wx-gantt-task-border-color: #1f6bd9;
|
||||
--wx-gantt-task-border: 1px solid transparent;
|
||||
|
||||
--wx-gantt-summary-color: #00ba94;
|
||||
--wx-gantt-summary-font-color: #ffffff;
|
||||
--wx-gantt-summary-fill-color: #099f81;
|
||||
--wx-gantt-summary-border-color: #099f81;
|
||||
--wx-gantt-summary-border: 1px solid transparent;
|
||||
|
||||
--wx-gantt-milestone-color: #ad44ab;
|
||||
|
||||
--wx-gantt-select-color: #eaedf5;
|
||||
--wx-gantt-link-color: #9fa1ae;
|
||||
--wx-gantt-link-marker-background: #eaedf5;
|
||||
--wx-gantt-link-marker-color: #9fa1ae;
|
||||
|
||||
--wx-gantt-bar-shadow: 0px 1px 2px rgba(44, 47, 60, 0.06),
|
||||
0px 3px 10px rgba(44, 47, 60, 0.12);
|
||||
|
||||
--wx-gantt-progress-marker-height: 26px;
|
||||
--wx-gantt-progress-border-color: #c0c3ce;
|
||||
|
||||
--wx-gantt-baseline-border-radius: 2px;
|
||||
|
||||
/* grid */
|
||||
--wx-grid-header-font: var(--wx-font-weight-md) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-grid-header-font-color: var(--wx-color-font);
|
||||
--wx-grid-header-text-transform: capitalize;
|
||||
--wx-grid-header-shadow: none;
|
||||
|
||||
--wx-grid-body-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-grid-body-font-color: var(--wx-color-font);
|
||||
--wx-grid-body-row-border: var(--wx-gantt-border);
|
||||
--wx-grid-body-cell-border: 1px solid transparent;
|
||||
|
||||
/* timescale */
|
||||
--wx-timescale-font: var(--wx-font-weight-md) var(--wx-font-size-sm)
|
||||
var(--wx-font-family);
|
||||
--wx-timescale-font-color: var(--wx-color-font);
|
||||
--wx-timescale-text-transform: uppercase;
|
||||
--wx-timescale-shadow: none;
|
||||
--wx-timescale-border: var(--wx-gantt-border);
|
||||
|
||||
/* grid and timescale */
|
||||
--wx-gantt-holiday-background: #f0f6fa;
|
||||
--wx-gantt-holiday-color: #9fa1ae;
|
||||
|
||||
/* markers */
|
||||
--wx-gantt-marker-font: var(--wx-font-weight-md) var(--wx-font-size-sm)
|
||||
var(--wx-font-family);
|
||||
--wx-gantt-marker-font-color: #fff;
|
||||
--wx-gantt-marker-color: rgba(6, 189, 248, 0.77);
|
||||
|
||||
/* tooltips */
|
||||
--wx-tooltip-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-tooltip-font-color: #e6e6e6;
|
||||
--wx-tooltip-background: #4f525a;
|
||||
|
||||
/* sidebar */
|
||||
--wx-sidebar-close-icon: #c0c3ce;
|
||||
}
|
||||
12
src/themes/Willow.jsx
Normal file
12
src/themes/Willow.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Willow as CoreWillow } from '@svar-ui/react-core';
|
||||
import './Willow.css';
|
||||
|
||||
function Willow({ fonts = true, children }) {
|
||||
return children ? (
|
||||
<CoreWillow fonts={fonts}>{children()}</CoreWillow>
|
||||
) : (
|
||||
<CoreWillow fonts={fonts} />
|
||||
);
|
||||
}
|
||||
|
||||
export default Willow;
|
||||
82
src/themes/WillowDark.css
Normal file
82
src/themes/WillowDark.css
Normal file
@@ -0,0 +1,82 @@
|
||||
.wx-willow-dark-theme {
|
||||
color-scheme: dark;
|
||||
|
||||
--wx-gantt-border-color: #384047;
|
||||
--wx-gantt-border: var(--wx-border);
|
||||
--wx-gantt-form-header-border: none;
|
||||
--wx-gantt-icon-color: #9fa1ae;
|
||||
|
||||
/* bars */
|
||||
--wx-gantt-bar-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-gantt-bar-border-radius: 3px;
|
||||
--wx-gantt-milestone-border-radius: 3px;
|
||||
|
||||
--wx-gantt-task-color: #37a9ef;
|
||||
--wx-gantt-task-font-color: #ffffffe5;
|
||||
--wx-gantt-task-fill-color: #098cdc;
|
||||
--wx-gantt-task-border-color: #098cdc;
|
||||
--wx-gantt-task-border: 1px solid transparent;
|
||||
|
||||
--wx-gantt-summary-color: #00ba94;
|
||||
--wx-gantt-summary-font-color: #ffffffe5;
|
||||
--wx-gantt-summary-fill-color: #099f81;
|
||||
--wx-gantt-summary-border-color: #099f81;
|
||||
--wx-gantt-summary-border: 1px solid transparent;
|
||||
|
||||
--wx-gantt-progress-marker-height: 26px;
|
||||
--wx-gantt-progress-border-color: #4b5359;
|
||||
|
||||
--wx-gantt-baseline-border-radius: 2px;
|
||||
|
||||
/* temp*/
|
||||
--wx-gantt-bar-shadow: 0px 1px 2px rgba(44, 47, 60, 0.06),
|
||||
0px 3px 10px rgba(44, 47, 60, 0.12);
|
||||
|
||||
--wx-gantt-milestone-color: #ad44ab;
|
||||
|
||||
--wx-gantt-select-color: #384047;
|
||||
--wx-gantt-link-color: #9fa1ae;
|
||||
--wx-gantt-link-marker-background: #384047;
|
||||
--wx-gantt-link-marker-color: #9fa1ae;
|
||||
|
||||
/* grid */
|
||||
--wx-grid-header-font: var(--wx-font-weight-md) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-grid-header-font-color: var(--wx-color-font);
|
||||
--wx-grid-header-text-transform: capitalize;
|
||||
--wx-grid-header-shadow: none;
|
||||
|
||||
--wx-grid-body-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-grid-body-font-color: var(--wx-color-font);
|
||||
--wx-grid-body-row-border: var(--wx-border);
|
||||
--wx-grid-body-cell-border: 1px solid transparent;
|
||||
|
||||
/* timescale */
|
||||
--wx-timescale-font: var(--wx-font-weight-md) var(--wx-font-size-sm)
|
||||
var(--wx-font-family);
|
||||
--wx-timescale-font-color: var(--wx-color-font);
|
||||
--wx-timescale-text-transform: uppercase;
|
||||
--wx-timescale-shadow: none;
|
||||
--wx-timescale-border: var(--wx-border);
|
||||
|
||||
/* grid and timescale */
|
||||
--wx-gantt-holiday-background: #303539;
|
||||
--wx-gantt-holiday-color: #878994;
|
||||
|
||||
/* markers */
|
||||
--wx-gantt-marker-font: var(--wx-font-weight-md) var(--wx-font-size-sm)
|
||||
var(--wx-font-family);
|
||||
--wx-gantt-marker-font-color: #fff;
|
||||
--wx-gantt-marker-color: rgba(6, 189, 248, 0.77);
|
||||
|
||||
/* tooltips */
|
||||
--wx-tooltip-font: var(--wx-font-weight) var(--wx-font-size)
|
||||
var(--wx-font-family);
|
||||
--wx-tooltip-font-color: #e6e6e6;
|
||||
--wx-tooltip-background: #4f525a;
|
||||
|
||||
/* sidebar */
|
||||
--wx-sidebar-close-icon: #384047;
|
||||
}
|
||||
10
src/themes/WillowDark.jsx
Normal file
10
src/themes/WillowDark.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { WillowDark as WillowDarkCore } from '@svar-ui/react-core';
|
||||
import './WillowDark.css';
|
||||
|
||||
export default function WillowDark({ fonts = true, children }) {
|
||||
if (children) {
|
||||
return <WillowDarkCore fonts={fonts}>{children()}</WillowDarkCore>;
|
||||
} else {
|
||||
return <WillowDarkCore fonts={fonts} />;
|
||||
}
|
||||
}
|
||||
36
src/widgets/IconButton.css
Normal file
36
src/widgets/IconButton.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.wx-button.wx-TyZ1fHBj {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.wx-primary.wx-TyZ1fHBj {
|
||||
color: var(--wx-color-primary-font);
|
||||
background-color: var(--wx-color-primary);
|
||||
}
|
||||
|
||||
.wx-primary.wx-TyZ1fHBj:hover {
|
||||
background-color: #0b9db1;
|
||||
}
|
||||
|
||||
.wx-transparent.wx-TyZ1fHBj {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
color: var(--wx-color-primary-font);
|
||||
background-color: rgba(99, 99, 99, 0.45);
|
||||
}
|
||||
|
||||
.wx-transparent.wx-TyZ1fHBj:hover {
|
||||
background-color: rgba(69, 69, 69, 0.45);
|
||||
}
|
||||
|
||||
.wx-button-icon.wx-TyZ1fHBj {
|
||||
font-size: 20px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
13
src/widgets/IconButton.jsx
Normal file
13
src/widgets/IconButton.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import './IconButton.css';
|
||||
|
||||
export default function IconButton({
|
||||
appearance = 'primary',
|
||||
icon = '',
|
||||
onClick,
|
||||
}) {
|
||||
return (
|
||||
<button className={`wx-TyZ1fHBj wx-button ${appearance}`} onClick={onClick}>
|
||||
{icon ? <i className={`wx-TyZ1fHBj wx-button-icon ${icon}`}></i> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
src/widgets/Tooltip.css
Normal file
21
src/widgets/Tooltip.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.wx-tooltip-area.wx-KG0Lwsqo {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wx-gantt-tooltip {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
box-shadow: var(--wx-box-shadow);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wx-gantt-tooltip-text.wx-KG0Lwsqo {
|
||||
padding: 6px 10px;
|
||||
background-color: var(--wx-tooltip-background);
|
||||
font: var(--wx-tooltip-font);
|
||||
color: var(--wx-tooltip-font-color);
|
||||
}
|
||||
143
src/widgets/Tooltip.jsx
Normal file
143
src/widgets/Tooltip.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './Tooltip.css';
|
||||
|
||||
function Tooltip(props) {
|
||||
const { api, content: Content, children } = props;
|
||||
|
||||
const areaRef = useRef(null);
|
||||
const tooltipNodeRef = useRef(null);
|
||||
|
||||
const [areaCoords, setAreaCoords] = useState({});
|
||||
const [tooltipData, setTooltipData] = useState(null);
|
||||
const [pos, setPos] = useState({});
|
||||
|
||||
function findAttribute(node) {
|
||||
while (node) {
|
||||
if (node.getAttribute) {
|
||||
const id = node.getAttribute('data-tooltip-id');
|
||||
const at = node.getAttribute('data-tooltip-at');
|
||||
const tooltip = node.getAttribute('data-tooltip');
|
||||
if (id || tooltip) return { id, tooltip, target: node, at };
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
return { id: null, tooltip: null, target: null, at: null };
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const tooltipNode = tooltipNodeRef.current;
|
||||
if (tooltipNode && pos && (pos.text || Content)) {
|
||||
const tooltipCoords = tooltipNode.getBoundingClientRect();
|
||||
|
||||
let updated = false;
|
||||
let newLeft = pos.left;
|
||||
let newTop = pos.top;
|
||||
|
||||
if (tooltipCoords.right >= areaCoords.right) {
|
||||
newLeft = areaCoords.width - tooltipCoords.width - 5;
|
||||
updated = true;
|
||||
}
|
||||
if (tooltipCoords.bottom >= areaCoords.bottom) {
|
||||
newTop = pos.top - (tooltipCoords.bottom - areaCoords.bottom + 2);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
setPos((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, left: newLeft, top: newTop };
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [pos, areaCoords, Content]);
|
||||
|
||||
const timerRef = useRef(null);
|
||||
const TIMEOUT = 300;
|
||||
const debounce = (code) => {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
code();
|
||||
}, TIMEOUT);
|
||||
};
|
||||
|
||||
function move(e) {
|
||||
let { id, tooltip, target, at } = findAttribute(e.target);
|
||||
setPos(null);
|
||||
setTooltipData(null);
|
||||
|
||||
if (!tooltip) {
|
||||
if (!id) {
|
||||
clearTimeout(timerRef.current);
|
||||
return;
|
||||
} else {
|
||||
tooltip = getTaskText(id);
|
||||
}
|
||||
}
|
||||
|
||||
const clientX = e.clientX;
|
||||
|
||||
debounce(() => {
|
||||
if (id) {
|
||||
setTooltipData(getTaskObj(prepareId(id)));
|
||||
}
|
||||
|
||||
const targetCoords = target.getBoundingClientRect();
|
||||
const areaEl = areaRef.current;
|
||||
const areaRect = areaEl
|
||||
? areaEl.getBoundingClientRect()
|
||||
: { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
||||
|
||||
let top, left;
|
||||
if (at === 'left') {
|
||||
top = targetCoords.top + 5 - areaRect.top;
|
||||
left = targetCoords.right + 5 - areaRect.left;
|
||||
} else {
|
||||
top = targetCoords.top + targetCoords.height - areaRect.top;
|
||||
left = clientX - areaRect.left;
|
||||
}
|
||||
|
||||
setAreaCoords(areaRect);
|
||||
setPos({ top, left, text: tooltip });
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskObj(id) {
|
||||
return api?.getTask(prepareId(id)) || null;
|
||||
}
|
||||
|
||||
function getTaskText(id) {
|
||||
return getTaskObj(id)?.text || '';
|
||||
}
|
||||
|
||||
function prepareId(id) {
|
||||
const numId = parseInt(id);
|
||||
return isNaN(numId) ? id : numId;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="wx-KG0Lwsqo wx-tooltip-area"
|
||||
ref={areaRef}
|
||||
onMouseMove={move}
|
||||
>
|
||||
{pos && (pos.text || Content) ? (
|
||||
<div
|
||||
className="wx-KG0Lwsqo wx-gantt-tooltip"
|
||||
ref={tooltipNodeRef}
|
||||
style={{ top: `${pos.top}px`, left: `${pos.left}px` }}
|
||||
>
|
||||
{Content ? (
|
||||
<Content data={getTaskObj(tooltipData)} />
|
||||
) : pos.text ? (
|
||||
<div className="wx-KG0Lwsqo wx-gantt-tooltip-text">{pos.text}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tooltip;
|
||||
Reference in New Issue
Block a user