waveterm/frontend/app/tab/tabbar.tsx
Evan Simkowitz 03587184a0
Replace logo with platform-dependent button to open app menu (#239)
Replace the logo in the tab bar with an ellipsis icon that shows on
Linux and Windows and, when clicked, opens up the system menus. This
also fixes an issue I noticed where the context menus were set to the
wrong coordinates when a window was zoomed in.


![image](https://github.com/user-attachments/assets/1be77cad-73a6-4cb8-b545-08ec908f1d9a)

![image](https://github.com/user-attachments/assets/0beef938-15f8-4084-b7bd-7d9f995187ef)
2024-08-16 16:18:42 -07:00

557 lines
22 KiB
TypeScript

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { WindowDrag } from "@/element/windowdrag";
import { deleteLayoutModelForTab } from "@/layout/index";
import { atoms, getApi, isDev, PLATFORM } from "@/store/global";
import * as services from "@/store/services";
import { useAtomValue } from "jotai";
import { OverlayScrollbars } from "overlayscrollbars";
import React, { createRef, useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "throttle-debounce";
import { Button } from "../element/button";
import { Tab } from "./tab";
import "./tabbar.less";
const TAB_DEFAULT_WIDTH = 130;
const TAB_MIN_WIDTH = 100;
const DRAGGER_RIGHT_MIN_WIDTH = 74;
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 = React.memo(({ workspace }: TabBarProps) => {
const [tabIds, setTabIds] = useState<string[]>([]);
const [dragStartPositions, setDragStartPositions] = useState<number[]>([]);
const [draggingTab, setDraggingTab] = useState<string>();
const [tabsLoaded, setTabsLoaded] = useState({});
// const [scrollable, setScrollable] = useState(false);
// const [tabWidth, setTabWidth] = useState(TAB_DEFAULT_WIDTH);
const [newTabId, setNewTabId] = useState<string | null>(null);
const tabbarWrapperRef = useRef<HTMLDivElement>(null);
const tabBarRef = useRef<HTMLDivElement>(null);
const tabsWrapperRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<React.RefObject<HTMLDivElement>[]>([]);
const addBtnRef = useRef<HTMLDivElement>(null);
const draggingTimeoutIdRef = useRef<NodeJS.Timeout>(null);
const scrollToNewTabTimeoutIdRef = useRef<NodeJS.Timeout>(null);
const newTabIdTimeoutIdRef = useRef<NodeJS.Timeout>(null);
const draggingRemovedRef = useRef(false);
const draggingTabDataRef = useRef({
tabId: "",
ref: { current: null },
tabStartX: 0,
tabIndex: 0,
initialOffsetX: null,
totalScrollOffset: null,
dragged: false,
});
const osInstanceRef = useRef<OverlayScrollbars>(null);
const draggerRightRef = useRef<HTMLDivElement>(null);
const draggerLeftRef = useRef<HTMLDivElement>(null);
const tabWidthRef = useRef<number>(TAB_DEFAULT_WIDTH);
const scrollableRef = useRef<boolean>(false);
const updateStatusButtonRef = useRef<HTMLButtonElement>(null);
const windowData = useAtomValue(atoms.waveWindow);
const { activetabid } = windowData;
const isFullScreen = useAtomValue(atoms.isFullScreen);
const appUpdateStatus = useAtomValue(atoms.updaterStatusAtom);
let prevDelta: number;
let prevDragDirection: string;
// Update refs when tabIds change
useEffect(() => {
tabRefs.current = tabIds.map((_, index) => tabRefs.current[index] || createRef());
}, [tabIds]);
useEffect(() => {
if (workspace) {
// Compare current tabIds with new workspace.tabids
const currentTabIds = new Set(tabIds);
const newTabIds = new Set(workspace.tabids);
const areEqual =
currentTabIds.size === newTabIds.size && [...currentTabIds].every((id) => newTabIds.has(id));
if (!areEqual) {
setTabIds(workspace.tabids);
}
}
}, [workspace, tabIds]);
const saveTabsPosition = useCallback(() => {
const tabs = tabRefs.current;
if (tabs === null) return;
const newStartPositions: number[] = [];
let cumulativeLeft = 0; // Start from the left edge
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 debouncedSaveTabsPosition = debounce(100, () => saveTabsPosition());
const updateSizeAndPosition = (animate?: boolean) => {
const tabBar = tabBarRef.current;
if (tabBar === null) return;
const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width;
const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width;
const addBtnWidth = addBtnRef.current.getBoundingClientRect().width;
const updateStatusLabelWidth = updateStatusButtonRef.current?.getBoundingClientRect().width ?? 0;
const spaceForTabs =
tabbarWrapperWidth - (windowDragLeftWidth + DRAGGER_RIGHT_MIN_WIDTH + addBtnWidth + updateStatusLabelWidth);
const numberOfTabs = tabIds.length;
const totalDefaultTabWidth = numberOfTabs * TAB_DEFAULT_WIDTH;
const minTotalTabWidth = numberOfTabs * TAB_MIN_WIDTH;
const tabWidth = tabWidthRef.current;
const scrollable = scrollableRef.current;
let newTabWidth = tabWidth;
let newScrollable = scrollable;
if (spaceForTabs < totalDefaultTabWidth && spaceForTabs > minTotalTabWidth) {
newTabWidth = TAB_MIN_WIDTH;
} else if (minTotalTabWidth > spaceForTabs) {
// Case where tabs cannot shrink further, make the tab bar scrollable
newTabWidth = TAB_MIN_WIDTH;
newScrollable = true;
} else if (totalDefaultTabWidth > spaceForTabs) {
// Case where resizing is needed due to limited container width
newTabWidth = spaceForTabs / numberOfTabs;
newScrollable = false;
} else {
// 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
tabRefs.current.forEach((ref, index) => {
if (ref.current) {
if (animate) {
ref.current.classList.add("animate");
} else {
ref.current.classList.remove("animate");
}
ref.current.style.width = `${newTabWidth}px`;
ref.current.style.transform = `translate3d(${index * newTabWidth}px,0,0)`;
}
});
// Update the state with the new tab width if it has changed
if (newTabWidth !== tabWidth) {
tabWidthRef.current = newTabWidth;
}
// Update the state with the new scrollable state if it has changed
if (newScrollable !== scrollable) {
scrollableRef.current = newScrollable;
}
// Initialize/destroy overlay scrollbars
if (newScrollable) {
osInstanceRef.current = OverlayScrollbars(tabBarRef.current, { ...(OS_OPTIONS as any) });
} else {
if (osInstanceRef.current) {
osInstanceRef.current.destroy();
}
}
};
const handleResizeTabs = useCallback(() => {
updateSizeAndPosition();
debouncedSaveTabsPosition();
}, [tabIds, newTabId, isFullScreen]);
useEffect(() => {
window.addEventListener("resize", () => handleResizeTabs());
return () => {
window.removeEventListener("resize", () => handleResizeTabs());
};
}, [handleResizeTabs]);
useEffect(() => {
// Check if all tabs are loaded
const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]);
if (allLoaded) {
updateSizeAndPosition(newTabId === null);
saveTabsPosition();
}
}, [tabIds, tabsLoaded, newTabId, handleResizeTabs, saveTabsPosition]);
// Make sure timeouts are cleared when component is unmounted
useEffect(() => {
return () => {
if (draggingTimeoutIdRef.current) {
clearTimeout(draggingTimeoutIdRef.current);
}
if (scrollToNewTabTimeoutIdRef.current) {
clearTimeout(scrollToNewTabTimeoutIdRef.current);
}
if (newTabIdTimeoutIdRef.current) {
clearTimeout(newTabIdTimeoutIdRef.current);
}
};
}, []);
const getDragDirection = (currentX: number) => {
let dragDirection;
if (currentX - prevDelta > 0) {
dragDirection = "+";
} else if (currentX - prevDelta === 0) {
dragDirection = prevDragDirection;
} else {
dragDirection = "-";
}
prevDelta = currentX;
prevDragDirection = dragDirection;
return dragDirection;
};
const getNewTabIndex = (currentX: number, tabIndex: number, dragDirection: string) => {
let newTabIndex = tabIndex;
const tabWidth = tabWidthRef.current;
if (dragDirection === "+") {
// Dragging to the right
for (let i = tabIndex + 1; i < tabIds.length; i++) {
const otherTabStart = dragStartPositions[i];
if (currentX + tabWidth > otherTabStart + tabWidth / 2) {
newTabIndex = i;
}
}
} else {
// Dragging to the left
for (let i = tabIndex - 1; i >= 0; i--) {
const otherTabEnd = dragStartPositions[i] + tabWidth;
if (currentX < otherTabEnd - tabWidth / 2) {
newTabIndex = i;
}
}
}
return newTabIndex;
};
const handleMouseMove = (event: MouseEvent) => {
const { tabId, ref, tabStartX } = draggingTabDataRef.current;
let initialOffsetX = draggingTabDataRef.current.initialOffsetX;
let totalScrollOffset = draggingTabDataRef.current.totalScrollOffset;
if (initialOffsetX === null) {
initialOffsetX = event.clientX - tabStartX;
draggingTabDataRef.current.initialOffsetX = initialOffsetX;
}
let currentX = event.clientX - initialOffsetX - totalScrollOffset;
let tabBarRectWidth = tabBarRef.current.getBoundingClientRect().width;
// for macos, it's offset to make space for the window buttons
const tabBarRectLeftOffset = tabBarRef.current.getBoundingClientRect().left;
const incrementDecrement = tabBarRectLeftOffset * 0.05;
const dragDirection = getDragDirection(currentX);
const scrollable = scrollableRef.current;
const tabWidth = tabWidthRef.current;
// Scroll the tab bar if the dragged tab overflows the container bounds
if (scrollable) {
const { viewport } = osInstanceRef.current.elements();
const currentScrollLeft = viewport.scrollLeft;
if (event.clientX <= tabBarRectLeftOffset) {
viewport.scrollLeft = Math.max(0, currentScrollLeft - incrementDecrement); // Scroll left
if (viewport.scrollLeft !== currentScrollLeft) {
// Only adjust if the scroll actually changed
draggingTabDataRef.current.totalScrollOffset += currentScrollLeft - viewport.scrollLeft;
}
} else if (event.clientX >= tabBarRectWidth + tabBarRectLeftOffset) {
viewport.scrollLeft = Math.min(viewport.scrollWidth, currentScrollLeft + incrementDecrement); // Scroll right
if (viewport.scrollLeft !== currentScrollLeft) {
// Only adjust if the scroll actually changed
draggingTabDataRef.current.totalScrollOffset -= viewport.scrollLeft - currentScrollLeft;
}
}
}
// Re-calculate currentX after potential scroll adjustment
initialOffsetX = draggingTabDataRef.current.initialOffsetX;
totalScrollOffset = draggingTabDataRef.current.totalScrollOffset;
currentX = event.clientX - initialOffsetX - totalScrollOffset;
setDraggingTab((prev) => (prev !== tabId ? tabId : prev));
// Check if the tab has moved 5 pixels
if (Math.abs(currentX - tabStartX) >= 50) {
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 = `translate3d(${currentX}px,0,0)`;
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
if (!draggingRemovedRef.current) {
tabIds.splice(tabIndex, 1);
draggingRemovedRef.current = true;
}
// Find current index of the dragged tab in tempTabs
const currentIndexOfDraggingTab = tabIds.indexOf(tabId);
// Move the dragged tab to its new position
if (currentIndexOfDraggingTab !== -1) {
tabIds.splice(currentIndexOfDraggingTab, 1);
}
tabIds.splice(newTabIndex, 0, tabId);
// Update visual positions of the tabs
tabIds.forEach((localTabId, index) => {
const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === localTabId);
if (ref.current && localTabId !== tabId) {
ref.current.style.transform = `translate3d(${index * tabWidth}px,0,0)`;
ref.current.classList.add("animate");
}
});
draggingTabDataRef.current.tabIndex = newTabIndex;
}
};
const handleMouseUp = (event: MouseEvent) => {
const { tabIndex, dragged } = draggingTabDataRef.current;
// Update the final position of the dragged tab
const draggingTab = tabIds[tabIndex];
const tabWidth = tabWidthRef.current;
const finalLeftPosition = tabIndex * tabWidth;
const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === draggingTab);
if (ref.current) {
ref.current.classList.add("animate");
ref.current.style.transform = `translate3d(${finalLeftPosition}px,0,0)`;
}
if (dragged) {
draggingTimeoutIdRef.current = setTimeout(() => {
// Reset styles
tabRefs.current.forEach((ref) => {
ref.current.style.zIndex = "0";
ref.current.classList.remove("animate");
});
// Reset dragging state
setDraggingTab(null);
// Update workspace tab ids
services.ObjectService.UpdateWorkspaceTabIds(workspace.oid, tabIds);
}, 300);
} else {
// Reset styles
tabRefs.current.forEach((ref) => {
ref.current.style.zIndex = "0";
ref.current.classList.remove("animate");
});
// Reset dragging state
setDraggingTab(null);
}
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
draggingRemovedRef.current = false;
};
const handleDragStart = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>, tabId: string, ref: React.RefObject<HTMLDivElement>) => {
if (event.button !== 0) return;
const tabIndex = tabIds.indexOf(tabId);
const tabStartX = dragStartPositions[tabIndex]; // Starting X position of the tab
if (ref.current) {
draggingTabDataRef.current = {
tabId: ref.current.dataset.tabId,
ref,
tabStartX,
tabIndex,
initialOffsetX: null,
totalScrollOffset: 0,
dragged: false,
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
if (draggingTimeoutIdRef.current) {
clearTimeout(draggingTimeoutIdRef.current);
}
}
},
[tabIds, dragStartPositions]
);
const handleSelectTab = (tabId: string) => {
if (!draggingTabDataRef.current.dragged) {
services.ObjectService.SetActiveTab(tabId);
}
};
const handleAddTab = () => {
const newTabName = `T${tabIds.length + 1}`;
services.ObjectService.AddTabToWorkspace(newTabName, true).then((tabId) => {
setTabIds([...tabIds, tabId]);
setNewTabId(tabId);
});
services.ObjectService.GetObject;
tabsWrapperRef.current.style.transition;
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
scrollToNewTabTimeoutIdRef.current = setTimeout(() => {
if (scrollableRef.current) {
const { viewport } = osInstanceRef.current.elements();
viewport.scrollLeft = tabIds.length * tabWidthRef.current;
}
}, 30);
newTabIdTimeoutIdRef.current = setTimeout(() => {
setNewTabId(null);
}, 100);
};
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
event?.stopPropagation();
services.WindowService.CloseTab(tabId);
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutModelForTab(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;
};
function onEllipsisClick() {
getApi().showContextMenu();
}
const tabsWrapperWidth = tabIds.length * tabWidthRef.current;
const devLabel = isDev() ? (
<div className="dev-label">
<i className="fa fa-brands fa-dev fa-fw" />
</div>
) : undefined;
const appMenuButton =
PLATFORM !== "darwin" ? (
<div className="app-menu-button" onClick={onEllipsisClick}>
<i className="fa fa-ellipsis" />
</div>
) : undefined;
function onUpdateAvailableClick() {
getApi().installAppUpdate();
}
let updateAvailableLabel: React.ReactNode = null;
if (appUpdateStatus === "ready") {
updateAvailableLabel = (
<Button
forwardedRef={updateStatusButtonRef}
className="update-available-button"
title="Click to Install Update"
onClick={onUpdateAvailableClick}
>
Update Available
</Button>
);
}
return (
<div ref={tabbarWrapperRef} className="tab-bar-wrapper">
<WindowDrag ref={draggerLeftRef} className="left" />
{appMenuButton}
{devLabel}
<div className="tab-bar" ref={tabBarRef} data-overlayscrollbars-initialize>
<div className="tabs-wrapper" ref={tabsWrapperRef} style={{ width: `${tabsWrapperWidth}px` }}>
{tabIds.map((tabId, index) => {
return (
<Tab
key={tabId}
ref={tabRefs.current[index]}
id={tabId}
isFirst={index === 0}
onSelect={() => handleSelectTab(tabId)}
active={activetabid === tabId}
onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])}
onClose={(event) => handleCloseTab(event, tabId)}
onLoaded={() => handleTabLoaded(tabId)}
isBeforeActive={isBeforeActive(tabId)}
isDragging={draggingTab === tabId}
tabWidth={tabWidthRef.current}
isNew={tabId === newTabId}
/>
);
})}
</div>
</div>
<div ref={addBtnRef} className="add-tab-btn" onClick={handleAddTab}>
<i className="fa fa-solid fa-plus fa-fw" />
</div>
<WindowDrag ref={draggerRightRef} className="right" />
{updateAvailableLabel}
</div>
);
});
export { TabBar };