590 lines
16 KiB
JavaScript
590 lines
16 KiB
JavaScript
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)
|
|
return;
|
|
if (hasFocus && scrollTaskStore) {
|
|
const { id } = scrollTaskStore;
|
|
const node = containerRef.current?.querySelector(
|
|
`.wx-bar[data-id='${id}']`,
|
|
);
|
|
if (node) node.focus({ preventScroll: true });
|
|
}
|
|
}, [scrollTaskStore]);
|
|
|
|
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 && task.$skip_baseline) 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}>
|
|
{!task.$skip && <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;
|