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 (