From e83b5c87633e028ee202bfa0ab89368bc8f2c257 Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Fri, 21 Jun 2024 11:04:00 +0800 Subject: [PATCH] Scrollable tabs using OverlayScrollbars (#60) --- frontend/app/app.less | 7 + frontend/app/app.tsx | 4 +- frontend/app/tab/tab.tsx | 14 +- frontend/app/tab/tabbar.less | 16 +- frontend/app/tab/tabbar.tsx | 303 +++++++++++++++++++++++------------ frontend/app/theme.less | 6 + frontend/types/gotypes.d.ts | 4 +- package.json | 1 + yarn.lock | 8 + 9 files changed, 253 insertions(+), 110 deletions(-) diff --git a/frontend/app/app.less b/frontend/app/app.less index fbf28ce55..24c2347ae 100644 --- a/frontend/app/app.less +++ b/frontend/app/app.less @@ -60,3 +60,10 @@ body { .error-boundary { color: var(--error-color); } + +/* OverlayScrollbars styling */ +.os-scrollbar { + --os-handle-bg: var(--scrollbar-thumb-color); + --os-handle-bg-hover: var(--scrollbar-thumb-hover-color); + --os-handle-bg-active: var(--scrollbar-thumb-active-color); +} diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 33d17811b..396cd5869 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -9,9 +9,11 @@ import { Provider } from "jotai"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; -import "./app.less"; import { CenteredDiv } from "./element/quickelems"; +import "overlayscrollbars/overlayscrollbars.css"; +import "./app.less"; + const App = () => { return ( diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 9f1ff225e..d43224e20 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -4,7 +4,7 @@ import { Button } from "@/element/button"; import * as WOS from "@/store/wos"; import { clsx } from "clsx"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import "./tab.less"; @@ -16,13 +16,23 @@ interface TabProps { onSelect: () => void; onClose: () => void; onDragStart: () => void; + onLoaded: () => void; } const Tab = React.forwardRef( - ({ id, active, isBeforeActive, isDragging, onSelect, onClose, onDragStart }, ref) => { + ({ id, active, isBeforeActive, isDragging, onLoaded, onSelect, onClose, onDragStart }, ref) => { const [tabData, tabLoading] = WOS.useWaveObjectValue(WOS.makeORef("tab", id)); const name = tabData?.name ?? "..."; + const loadedRef = useRef(false); + + useEffect(() => { + if (!loadedRef.current) { + onLoaded(); + loadedRef.current = true; + } + }, [onLoaded]); + return (
([]); -const tabWidthAtom = atom(DEFAULT_TAB_WIDTH); -const dragStartPositionsAtom = atom([]); -const draggingTabAtom = atom(null) as PrimitiveAtom; -const loadingAtom = atom(true); +const TAB_DEFAULT_WIDTH = 130; +const TAB_MIN_WIDTH = 100; +const OS_OPTIONS = { + overflow: { + x: "scroll", + y: "hidden", + }, + scrollbars: { + theme: "os-theme-dark", + visibility: "auto", + autoHide: "leave", + autoHideDelay: 1300, + autoHideSuspend: false, + dragScroll: true, + clickScroll: false, + pointers: ["mouse", "touch", "pen"], + }, +}; interface TabBarProps { workspace: Workspace; } const TabBar = ({ workspace }: TabBarProps) => { - const [tabIds, setTabIds] = useAtom(tabIdsAtom); - const [tabWidth, setTabWidth] = useAtom(tabWidthAtom); - const [dragStartPositions, setDragStartPositions] = useAtom(dragStartPositionsAtom); - const [draggingTab, setDraggingTab] = useAtom(draggingTabAtom); - const [loading, setLoading] = useAtom(loadingAtom); + const [tabIds, setTabIds] = useState([]); + const [dragStartPositions, setDragStartPositions] = useState([]); + const [draggingTab, setDraggingTab] = useState(); + const [tabsLoaded, setTabsLoaded] = useState({}); + const [scrollable, setScrollable] = useState(false); + const [tabWidth, setTabWidth] = useState(TAB_DEFAULT_WIDTH); const tabBarRef = useRef(null); + const tabsWrapperRef = useRef(null); const tabRefs = useRef[]>([]); const addBtnRef = useRef(null); - const draggingTimeoutId = useRef(null); + const draggingTimeoutIdRef = useRef(null); + const scrollToNewTabTimeoutIdRef = useRef(null); const draggingRemovedRef = useRef(false); const draggingTabDataRef = useRef({ tabId: "", @@ -43,13 +58,13 @@ const TabBar = ({ workspace }: TabBarProps) => { tabIndex: 0, dragged: false, }); + const osInstanceRef = useRef(null); const windowData = useAtomValue(atoms.waveWindow); const { activetabid } = windowData; let prevDelta: number; let prevDragDirection: string; - let shrunk: boolean; // Update refs when tabIds change useEffect(() => { @@ -68,40 +83,53 @@ const TabBar = ({ workspace }: TabBarProps) => { if (!areEqual) { setTabIds(workspace.tabids); } - setLoading(false); } - }, [workspace, tabIds, setTabIds, setLoading]); + }, [workspace, tabIds]); const updateTabPositions = useCallback(() => { - if (tabBarRef.current) { - const newStartPositions: number[] = []; - let cumulativeLeft = 0; // Start from the left edge + const tabs = tabRefs.current; + if (tabs === null) return; - tabRefs.current.forEach((ref) => { - if (ref.current) { - newStartPositions.push(cumulativeLeft); - cumulativeLeft += ref.current.getBoundingClientRect().width; // Add each tab's actual width to the cumulative position - } - }); + const newStartPositions: number[] = []; + let cumulativeLeft = 0; // Start from the left edge - setDragStartPositions(newStartPositions); - } - }, [tabRefs.current, setDragStartPositions]); + tabRefs.current.forEach((ref) => { + if (ref.current) { + newStartPositions.push(cumulativeLeft); + cumulativeLeft += ref.current.getBoundingClientRect().width; // Add each tab's actual width to the cumulative position + } + }); + + setDragStartPositions(newStartPositions); + }, []); + + const debouncedSetTabWidth = debounce((width) => setTabWidth(width), 100); + const debouncedSetScrollable = debounce((scrollable) => setScrollable(scrollable), 100); + const debouncedUpdateTabPositions = debounce(() => updateTabPositions(), 100); const handleResizeTabs = useCallback(() => { const tabBar = tabBarRef.current; - if (!tabBar) return; + if (tabBar === null) return; - const containerWidth = tabBar.getBoundingClientRect().width; + const tabBarWidth = tabBar.getBoundingClientRect().width; const numberOfTabs = tabIds.length; - const totalDefaultTabWidth = numberOfTabs * DEFAULT_TAB_WIDTH; - let newTabWidth = DEFAULT_TAB_WIDTH; + const totalDefaultTabWidth = numberOfTabs * TAB_DEFAULT_WIDTH; + const minTotalTabWidth = numberOfTabs * TAB_MIN_WIDTH; + let newTabWidth = tabWidth; + let newScrollable = scrollable; - if (totalDefaultTabWidth > containerWidth) { - newTabWidth = containerWidth / numberOfTabs; - shrunk = true; + if (minTotalTabWidth > tabBarWidth) { + // Case where tabs cannot shrink further, make the tab bar scrollable + newTabWidth = TAB_MIN_WIDTH; + newScrollable = true; + } else if (totalDefaultTabWidth > tabBarWidth) { + // Case where resizing is needed due to limited container width + newTabWidth = tabBarWidth / numberOfTabs; + newScrollable = false; } else { - shrunk = false; + // Case where tabs were previously shrunk or there is enough space for default width tabs + newTabWidth = TAB_DEFAULT_WIDTH; + newScrollable = false; } // Apply the calculated width and position to all tabs @@ -114,7 +142,19 @@ const TabBar = ({ workspace }: TabBarProps) => { // Update the state with the new tab width if it has changed if (newTabWidth !== tabWidth) { - setTabWidth(newTabWidth); + debouncedSetTabWidth(newTabWidth); + } + // Update the state with the new scrollable state if it has changed + if (newScrollable !== scrollable) { + debouncedSetScrollable(newScrollable); + } + // Initialize/destroy overlay scrollbars + if (newScrollable) { + osInstanceRef.current = OverlayScrollbars(tabBarRef.current, { ...(OS_OPTIONS as any) }); + } else { + if (osInstanceRef.current) { + osInstanceRef.current.destroy(); + } } // Update the position of the Add Tab button if needed @@ -123,66 +163,45 @@ const TabBar = ({ workspace }: TabBarProps) => { if (addButton && lastTabRef && lastTabRef.current) { const lastTabRect = lastTabRef.current.getBoundingClientRect(); addButton.style.position = "absolute"; - addButton.style.transform = `translateX(${lastTabRect.right}px) translateY(-50%)`; + if (newScrollable) { + addButton.style.transform = `translateX(${document.documentElement.clientWidth - addButton.offsetWidth}px) translateY(-50%)`; + } else { + addButton.style.transform = `translateX(${lastTabRect.right + 1}px) translateY(-50%)`; + } } - }, [tabIds, tabWidth, updateTabPositions, setTabWidth]); + + debouncedUpdateTabPositions(); + }, [tabIds, tabWidth, scrollable]); useEffect(() => { - window.addEventListener("resize", handleResizeTabs); + window.addEventListener("resize", () => handleResizeTabs()); return () => { - window.removeEventListener("resize", handleResizeTabs); + window.removeEventListener("resize", () => handleResizeTabs()); }; }, [handleResizeTabs]); useEffect(() => { - if (!loading) { - handleResizeTabs(); + // Check if all tabs are loaded + const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]); + if (allLoaded) { updateTabPositions(); + handleResizeTabs(); } - }, [loading, handleResizeTabs, updateTabPositions]); + }, [tabIds, tabsLoaded, handleResizeTabs, updateTabPositions]); // Make sure timeouts are cleared when component is unmounted useEffect(() => { return () => { - if (draggingTimeoutId.current) { - clearTimeout(draggingTimeoutId.current); + if (draggingTimeoutIdRef.current) { + clearTimeout(draggingTimeoutIdRef.current); + } + if (scrollToNewTabTimeoutIdRef.current) { + clearTimeout(scrollToNewTabTimeoutIdRef.current); } }; }, []); - const handleMouseMove = (event: MouseEvent) => { - const { tabId, ref, tabStartX } = draggingTabDataRef.current; - - let tabIndex = draggingTabDataRef.current.tabIndex; - let currentX = event.clientX - ref.current.getBoundingClientRect().width / 2; - - // Check if the tab has moved 5 pixels - if (Math.abs(currentX - tabStartX) >= 5) { - setDraggingTab(tabId); - draggingTabDataRef.current.dragged = true; - } - - // Constrain movement within the container bounds - if (tabBarRef.current) { - const numberOfTabs = tabIds.length; - const totalDefaultTabWidth = numberOfTabs * DEFAULT_TAB_WIDTH; - const containerRect = tabBarRef.current.getBoundingClientRect(); - let containerRectWidth = containerRect.width; - // Set to the total default tab width if there's vacant space - if (totalDefaultTabWidth < containerRectWidth) { - containerRectWidth = totalDefaultTabWidth; - } - - const minLeft = 0; - const maxRight = containerRectWidth - tabWidth; - - // Adjust currentX to stay within bounds - currentX = Math.min(Math.max(currentX, minLeft), maxRight); - } - - ref.current!.style.transform = `translateX(${currentX}px)`; - ref.current!.style.zIndex = "100"; - + const getDragDirection = (currentX: number) => { let dragDirection; if (currentX - prevDelta > 0) { dragDirection = "+"; @@ -193,9 +212,11 @@ const TabBar = ({ workspace }: TabBarProps) => { } prevDelta = currentX; prevDragDirection = dragDirection; + return dragDirection; + }; + const getNewTabIndex = (currentX: number, tabIndex: number, dragDirection: string) => { let newTabIndex = tabIndex; - if (dragDirection === "+") { // Dragging to the right for (let i = tabIndex + 1; i < tabIds.length; i++) { @@ -213,6 +234,63 @@ const TabBar = ({ workspace }: TabBarProps) => { } } } + return newTabIndex; + }; + + const handleMouseMove = (event: MouseEvent) => { + const { tabId, ref, tabStartX } = draggingTabDataRef.current; + + let currentX = event.clientX - ref.current.getBoundingClientRect().width / 2; + let tabBarRectWidth = tabBarRef.current.getBoundingClientRect().width; + const dragDirection = getDragDirection(currentX); + + // Scroll the tab bar if the dragged tab overflows the container bounds + if (scrollable) { + const { viewport } = osInstanceRef.current.elements(); + const { overflowAmount } = osInstanceRef.current.state(); + const { scrollOffsetElement } = osInstanceRef.current.elements(); + + if (event.clientX <= 0) { + viewport.scrollLeft = 0; + } else if (event.clientX >= tabBarRectWidth) { + viewport.scrollLeft = tabBarRectWidth; + } + + if (scrollOffsetElement.scrollLeft > 0) { + currentX += overflowAmount.x; + } + } + + // Check if the tab has moved 5 pixels + if (Math.abs(currentX - tabStartX) >= 5) { + setDraggingTab((prev) => (prev !== tabId ? tabId : prev)); + draggingTabDataRef.current.dragged = true; + } + + // Constrain movement within the container bounds + if (tabBarRef.current) { + const numberOfTabs = tabIds.length; + const totalDefaultTabWidth = numberOfTabs * TAB_DEFAULT_WIDTH; + if (totalDefaultTabWidth < tabBarRectWidth) { + // Set to the total default tab width if there's vacant space + tabBarRectWidth = totalDefaultTabWidth; + } else if (scrollable) { + // Set to the scrollable width if the tab bar is scrollable + tabBarRectWidth = tabsWrapperRef.current.scrollWidth; + } + + const minLeft = 0; + const maxRight = tabBarRectWidth - tabWidth; + + // Adjust currentX to stay within bounds + currentX = Math.min(Math.max(currentX, minLeft), maxRight); + } + + ref.current!.style.transform = `translateX(${currentX}px)`; + ref.current!.style.zIndex = "100"; + + const tabIndex = draggingTabDataRef.current.tabIndex; + const newTabIndex = getNewTabIndex(currentX, tabIndex, dragDirection); if (newTabIndex !== tabIndex) { // Remove the dragged tab if not already done @@ -239,7 +317,6 @@ const TabBar = ({ workspace }: TabBarProps) => { } }); - tabIndex = newTabIndex; draggingTabDataRef.current.tabIndex = newTabIndex; } }; @@ -257,7 +334,7 @@ const TabBar = ({ workspace }: TabBarProps) => { } if (dragged) { - draggingTimeoutId.current = setTimeout(() => { + draggingTimeoutIdRef.current = setTimeout(() => { // Reset styles tabRefs.current.forEach((ref) => { ref.current.style.zIndex = "0"; @@ -292,12 +369,12 @@ const TabBar = ({ workspace }: TabBarProps) => { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - if (draggingTimeoutId.current) { - clearTimeout(draggingTimeoutId.current); + if (draggingTimeoutIdRef.current) { + clearTimeout(draggingTimeoutIdRef.current); } } }, - [tabIds, dragStartPositions, tabWidth] + [tabIds, dragStartPositions] ); const handleSelectTab = (tabId: string) => { @@ -310,6 +387,13 @@ const TabBar = ({ workspace }: TabBarProps) => { const newTabName = `T${tabIds.length + 1}`; setTabIds([...tabIds, newTabName]); services.ObjectService.AddTabToWorkspace(newTabName, true); + + scrollToNewTabTimeoutIdRef.current = setTimeout(() => { + if (scrollable) { + const { viewport } = osInstanceRef.current.elements(); + viewport.scrollLeft = tabIds.length * tabWidth; + } + }, 30); }; const handleCloseTab = (tabId: string) => { @@ -317,26 +401,41 @@ const TabBar = ({ workspace }: TabBarProps) => { deleteLayoutStateAtomForTab(tabId); }; + const handleTabLoaded = useCallback((tabId) => { + setTabsLoaded((prev) => { + if (!prev[tabId]) { + // Only update if the tab isn't already marked as loaded + return { ...prev, [tabId]: true }; + } + return prev; + }); + }, []); + const isBeforeActive = (tabId: string) => { return tabIds.indexOf(tabId) === tabIds.indexOf(activetabid) - 1; }; + const tabsWrapperWidth = tabIds.length * tabWidth; + return (
-
- {tabIds.map((tabId, index) => ( - handleSelectTab(tabId)} - active={activetabid === tabId} - onDragStart={() => handleDragStart(tabId, tabRefs.current[index])} - onClose={() => handleCloseTab(tabId)} - isBeforeActive={isBeforeActive(tabId)} - isDragging={draggingTab === tabId} - /> - ))} +
+
+ {tabIds.map((tabId, index) => ( + handleSelectTab(tabId)} + active={activetabid === tabId} + onDragStart={() => handleDragStart(tabId, tabRefs.current[index])} + onClose={() => handleCloseTab(tabId)} + onLoaded={() => handleTabLoaded(tabId)} + isBeforeActive={isBeforeActive(tabId)} + isDragging={draggingTab === tabId} + /> + ))} +
diff --git a/frontend/app/theme.less b/frontend/app/theme.less index ac91fff2b..dcb5015a9 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -29,4 +29,10 @@ /* z-index values */ --zindex-header-hover: 100; --zindex-termstickers: 20; + + /* scrollbar colors */ + --scrollbar-background-color: var(--app-bg-color); + --scrollbar-thumb-color: rgba(255, 255, 255, 0.3); + --scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5); + --scrollbar-thumb-active-color: rgba(255, 255, 255, 0.6); } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index ba826528b..12297c12d 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -31,7 +31,7 @@ declare global { type BlockCommand = { command: string; - } & ( ResolveIdsCommand | BlockSetViewCommand | BlockSetMetaCommand | BlockGetMetaCommand | BlockMessageCommand | BlockInputCommand | BlockAppendFileCommand | BlockAppendIJsonCommand | CreateBlockCommand ); + } & ( ResolveIdsCommand | BlockSetViewCommand | BlockSetMetaCommand | BlockGetMetaCommand | BlockMessageCommand | BlockInputCommand | BlockAppendFileCommand | BlockAppendIJsonCommand | CreateBlockCommand ); // wstore.BlockDef type BlockDef = { @@ -325,4 +325,4 @@ declare global { } -export {} +export {} \ No newline at end of file diff --git a/package.json b/package.json index 5a57a66c2..8a9656ec1 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "immer": "^10.1.1", "jotai": "^2.8.0", "monaco-editor": "^0.49.0", + "overlayscrollbars": "^2.8.3", "react": "^18.3.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/yarn.lock b/yarn.lock index 953dd9052..41b3dfe57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10322,6 +10322,13 @@ __metadata: languageName: node linkType: hard +"overlayscrollbars@npm:^2.8.3": + version: 2.8.3 + resolution: "overlayscrollbars@npm:2.8.3" + checksum: 10c0/1f0713e981ce27e0cd9261a6a0acfe080bd44c21c9f6cbb9c3df582c155d11a0c949553d7ea4e01a107d784d527c5cf3465ab6979affca9e430d450968b763f6 + languageName: node + linkType: hard + "p-cancelable@npm:^2.0.0": version: 2.1.1 resolution: "p-cancelable@npm:2.1.1" @@ -12303,6 +12310,7 @@ __metadata: jotai: "npm:^2.8.0" less: "npm:^4.2.0" monaco-editor: "npm:^0.49.0" + overlayscrollbars: "npm:^2.8.3" prettier: "npm:^3.2.5" prettier-plugin-jsdoc: "npm:^1.3.0" prettier-plugin-organize-imports: "npm:^3.2.4"