This commit is contained in:
Marta Kowalska
2025-10-22 08:13:59 +00:00
commit 80ca059fb8
162 changed files with 16816 additions and 0 deletions

View 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);
}

View 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;

View 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
View 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;

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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;

View 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);
}

View 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;

View 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} />;
}

View 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;
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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>
);
}

View File

@@ -0,0 +1,4 @@
.date-time-controll.wx-hFsbgDln {
display: flex;
gap: 12px;
}

View 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>
);
}

View 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);
}

View 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,
)}
</>
);
}

View 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);
}

View 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;

View 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;
}

View 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>
);
}

View 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;

View 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;
}

View 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
View File

@@ -0,0 +1,4 @@
import { createContext } from 'react';
const store = createContext(null);
export default store;

14
src/full-css.js Normal file
View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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} />;
}
}

View 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;
}

View 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
View 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
View 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;