| | |
| | | return normalizePath(path1) === normalizePath(path2); |
| | | }; |
| | | |
| | | const LONG_PRESS_MS = 300; |
| | | |
| | | const TabsBar = () => { |
| | | const location = useLocation(); |
| | | const navigate = useNavigate(); |
| | |
| | | const [contextMenuTab, setContextMenuTab] = useState(null); |
| | | const contextMenuOpenRef = useRef(false); |
| | | contextMenuOpenRef.current = contextMenu !== null; |
| | | |
| | | const [draggingIndex, setDraggingIndex] = useState(null); |
| | | const [dropIndicatorIndex, setDropIndicatorIndex] = useState(null); |
| | | const longPressTimerRef = useRef(null); |
| | | const longPressIndexRef = useRef(null); |
| | | const justFinishedDragRef = useRef(false); |
| | | |
| | | // 在标签页右键,阻止浏览器默认菜单 |
| | | useEffect(() => { |
| | |
| | | |
| | | // 切换标签页 |
| | | const handleTabChange = (event, newValue) => { |
| | | if (justFinishedDragRef.current) { |
| | | justFinishedDragRef.current = false; |
| | | return; |
| | | } |
| | | const targetTab = tabs[newValue]; |
| | | if (targetTab && targetTab.path !== location.pathname) { |
| | | navigate(targetTab.path); |
| | |
| | | return () => document.removeEventListener('mousedown', onDocClick, true); |
| | | }, [contextMenu]); |
| | | |
| | | const clearLongPressTimer = useCallback(() => { |
| | | if (longPressTimerRef.current) { |
| | | clearTimeout(longPressTimerRef.current); |
| | | longPressTimerRef.current = null; |
| | | } |
| | | longPressIndexRef.current = null; |
| | | }, []); |
| | | |
| | | const handleTabPointerDown = useCallback((e, index) => { |
| | | if (index < 0) return; |
| | | longPressIndexRef.current = index; |
| | | longPressTimerRef.current = setTimeout(() => { |
| | | longPressTimerRef.current = null; |
| | | setDraggingIndex(index); |
| | | setDropIndicatorIndex(index); |
| | | }, LONG_PRESS_MS); |
| | | }, []); |
| | | |
| | | const handleTabPointerUp = useCallback(() => { |
| | | clearLongPressTimer(); |
| | | }, [clearLongPressTimer]); |
| | | |
| | | useEffect(() => { |
| | | if (draggingIndex === null) return; |
| | | const getDropIndex = (clientX) => { |
| | | const nodes = tabsBarRef.current?.querySelectorAll('[data-tab-index]'); |
| | | if (!nodes?.length) return draggingIndex; |
| | | for (let i = 0; i < nodes.length; i++) { |
| | | const rect = nodes[i].getBoundingClientRect(); |
| | | const mid = rect.left + rect.width / 2; |
| | | if (clientX <= mid) { |
| | | const drop = i; |
| | | if (draggingIndex === 0) return drop <= 0 ? 0 : draggingIndex; |
| | | return drop <= 0 ? 1 : drop; |
| | | } |
| | | } |
| | | const drop = nodes.length; |
| | | return draggingIndex === 0 ? 0 : drop; |
| | | }; |
| | | const clientXFromEvent = (e) => e.clientX ?? e.touches?.[0]?.clientX; |
| | | const onMove = (e) => { |
| | | const x = clientXFromEvent(e); |
| | | if (x != null) setDropIndicatorIndex(getDropIndex(x)); |
| | | }; |
| | | const onTouchMove = (e) => { |
| | | e.preventDefault(); |
| | | onMove(e); |
| | | }; |
| | | const onUp = () => { |
| | | justFinishedDragRef.current = true; |
| | | setDraggingIndex((di) => { |
| | | setDropIndicatorIndex((dropIdx) => { |
| | | if (di !== null && dropIdx !== null && di !== dropIdx) { |
| | | const newTabs = [...tabs]; |
| | | const [item] = newTabs.splice(di, 1); |
| | | const insertAt = dropIdx > di ? dropIdx - 1 : dropIdx; |
| | | newTabs.splice(insertAt, 0, item); |
| | | const dashboard = newTabs.find((t) => t.path === '/dashboard'); |
| | | if (dashboard && newTabs[0].path !== '/dashboard') { |
| | | const idx = newTabs.indexOf(dashboard); |
| | | newTabs.splice(idx, 1); |
| | | newTabs.unshift(dashboard); |
| | | } |
| | | saveTabs(newTabs); |
| | | setTabs(newTabs); |
| | | } |
| | | return null; |
| | | }); |
| | | return null; |
| | | }); |
| | | }; |
| | | document.addEventListener('mousemove', onMove, true); |
| | | document.addEventListener('mouseup', onUp, true); |
| | | document.addEventListener('touchmove', onTouchMove, { passive: false, capture: true }); |
| | | document.addEventListener('touchend', onUp, true); |
| | | document.addEventListener('touchcancel', onUp, true); |
| | | return () => { |
| | | document.removeEventListener('mousemove', onMove, true); |
| | | document.removeEventListener('mouseup', onUp, true); |
| | | document.removeEventListener('touchmove', onTouchMove, true); |
| | | document.removeEventListener('touchend', onUp, true); |
| | | document.removeEventListener('touchcancel', onUp, true); |
| | | }; |
| | | }, [draggingIndex, tabs]); |
| | | |
| | | return ( |
| | | <Box |
| | | ref={tabsBarRef} |
| | |
| | | key={tab.path} |
| | | label={ |
| | | <Box |
| | | data-tab-index={index} |
| | | onContextMenu={(e) => handleContextMenu(e, tab)} |
| | | onMouseDown={(e) => handleTabPointerDown(e, index)} |
| | | onMouseUp={handleTabPointerUp} |
| | | onMouseLeave={handleTabPointerUp} |
| | | onTouchStart={(e) => handleTabPointerDown(e, index)} |
| | | onTouchEnd={handleTabPointerUp} |
| | | onTouchCancel={handleTabPointerUp} |
| | | sx={{ |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 0.5, |
| | | width: '100%', |
| | | ...(draggingIndex === index && { opacity: 0.7 }), |
| | | }} |
| | | > |
| | | {tab.path === '/dashboard' && ( |