mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
DnD tabs (#44)
This commit is contained in:
parent
9adecebbf2
commit
b6c85e38f6
@ -1,8 +1,8 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
/* Copyright 2024, Command Line Inc. */
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
|
||||
.wave-button {
|
||||
background: none;
|
||||
.button {
|
||||
background: var(--accent-color);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
outline: inherit;
|
||||
@ -18,14 +18,16 @@
|
||||
-webkit-user-select: none;
|
||||
|
||||
color: var(--main-text-color);
|
||||
background: var(--accent-color);
|
||||
|
||||
i {
|
||||
fill: var(--main-text-color);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
&.primary,
|
||||
&.secondary {
|
||||
color: var(--main-text-color);
|
||||
background: var(--accent-color);
|
||||
|
||||
i {
|
||||
fill: var(--main-text-color);
|
||||
}
|
||||
@ -35,7 +37,8 @@
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
&.primary.outlined {
|
||||
&.primary.outlined,
|
||||
&.primary.greyoutlined {
|
||||
background: none;
|
||||
border: 1px solid var(--accent-color);
|
||||
|
||||
@ -45,56 +48,50 @@
|
||||
}
|
||||
|
||||
&.primary.greyoutlined {
|
||||
background: none;
|
||||
border: 1px solid var(--secondary-text-color);
|
||||
border-color: var(--secondary-text-color);
|
||||
|
||||
i {
|
||||
fill: var(--secondary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary.outlined.danger {
|
||||
border-color: var(--error-color);
|
||||
|
||||
i {
|
||||
fill: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary.outlined,
|
||||
&.primary.greyoutlined {
|
||||
&.hover-danger:hover {
|
||||
color: var(--main-text-color);
|
||||
border: 1px solid var(--error-color);
|
||||
border-color: var(--error-color);
|
||||
background: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary.outlined.danger {
|
||||
background: none;
|
||||
border: 1px solid var(--error-color);
|
||||
|
||||
i {
|
||||
fill: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.greytext {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
&.primary.ghost {
|
||||
background: none;
|
||||
|
||||
i {
|
||||
fill: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary.ghost.danger {
|
||||
background: none;
|
||||
i {
|
||||
fill: var(--app-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
color: var(--main-text-color);
|
||||
background: var(--highlight-bg-color);
|
||||
i {
|
||||
fill: var(--main-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary.outlined {
|
||||
@ -103,8 +100,7 @@
|
||||
}
|
||||
|
||||
&.secondary.outlined.danger {
|
||||
background: none;
|
||||
border: 1px solid var(--error-color);
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
&.secondary.ghost {
|
||||
|
@ -1,55 +1,28 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import "./button.less";
|
||||
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
disabled?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
autoFocus?: boolean;
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
className?: string;
|
||||
termInline?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
class Button extends React.Component<ButtonProps> {
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
className: "primary",
|
||||
};
|
||||
const Button: React.FC<ButtonProps> = ({ className = "primary", children, disabled, ...props }) => {
|
||||
const hasIcon = React.Children.toArray(children).some(
|
||||
(child) => React.isValidElement(child) && (child as React.ReactElement).type === "svg"
|
||||
);
|
||||
|
||||
handleClick(e) {
|
||||
if (this.props.onClick && !this.props.disabled) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className, title } = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx("wave-button", { disabled }, { "term-inline": termInline }, className)}
|
||||
onClick={this.handleClick.bind(this)}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
autoFocus={autoFocus}
|
||||
title={title}
|
||||
>
|
||||
{leftIcon && <span className="icon-left">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="icon-right">{rightIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={clsx("button", className, {
|
||||
disabled,
|
||||
hasIcon,
|
||||
})}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export { Button };
|
||||
export type { ButtonProps };
|
||||
|
@ -94,6 +94,11 @@ class ObjectServiceType {
|
||||
UpdateObjectMeta(oref: string, meta: MetaType): Promise<void> {
|
||||
return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
UpdateWorkspaceTabIds(workspaceId: string, tabIds: string[]): Promise<void> {
|
||||
return WOS.callBackendService("object", "UpdateWorkspaceTabIds", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
||||
export const ObjectService = new ObjectServiceType()
|
||||
|
@ -1,33 +1,105 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.tabcontent {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
.tab {
|
||||
cursor: pointer;
|
||||
width: 130px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
border-top: 2px solid transparent;
|
||||
background-color: rgba(0, 8, 3, 0);
|
||||
|
||||
.block-container {
|
||||
&.animate {
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
background-color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-top: 2px solid var(--tab-green);
|
||||
background-color: var(--tab-green);
|
||||
}
|
||||
|
||||
.name {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
-webkit-user-select: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.vertical-line {
|
||||
display: inline;
|
||||
width: 1px;
|
||||
height: 50%;
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
top: 50%;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.close {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1 0 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
top: 50%;
|
||||
z-index: 3;
|
||||
transform: translateY(-50%);
|
||||
right: 5px;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
i {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .close {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.vertical-line {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.mask {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.9) 20%,
|
||||
rgba(0, 0, 0, 0.8) 60%,
|
||||
rgba(0, 0, 0, 0.7) 100%
|
||||
);
|
||||
pointer-events: none; /* Prevents the background from capturing mouse events */
|
||||
}
|
||||
}
|
||||
|
||||
&.isDragging:not(.active) {
|
||||
background-color: rgba(0, 8, 3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-preview {
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
background-color: aquamarine;
|
||||
color: black;
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -1,66 +1,45 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Block, BlockHeader } from "@/app/block/block";
|
||||
import * as services from "@/store/services";
|
||||
import { Button } from "@/element/button";
|
||||
import * as WOS from "@/store/wos";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { CenteredDiv, CenteredLoadingDiv } from "@/element/quickelems";
|
||||
import { TileLayout } from "@/faraday/index";
|
||||
import { getLayoutStateAtomForTab } from "@/faraday/lib/layoutAtom";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import "./tab.less";
|
||||
|
||||
const TabContent = ({ tabId }: { tabId: string }) => {
|
||||
const oref = useMemo(() => WOS.makeORef("tab", tabId), [tabId]);
|
||||
const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]);
|
||||
const tabLoading = useAtomValue(loadingAtom);
|
||||
const tabAtom = useMemo(() => WOS.getWaveObjectAtom<Tab>(oref), [oref]);
|
||||
const layoutStateAtom = useMemo(() => getLayoutStateAtomForTab(tabId, tabAtom), [tabAtom, tabId]);
|
||||
const tabData = useAtomValue(tabAtom);
|
||||
interface TabProps {
|
||||
id: string;
|
||||
active: boolean;
|
||||
isBeforeActive: boolean;
|
||||
isDragging: boolean;
|
||||
onSelect: () => void;
|
||||
onClose: () => void;
|
||||
onDragStart: () => void;
|
||||
}
|
||||
|
||||
const renderBlock = useCallback((tabData: TabLayoutData, ready: boolean, onClose: () => void) => {
|
||||
// console.log("renderBlock", tabData);
|
||||
if (!tabData.blockId || !ready) {
|
||||
return null;
|
||||
}
|
||||
return <Block blockId={tabData.blockId} onClose={onClose} />;
|
||||
}, []);
|
||||
const Tab = React.forwardRef<HTMLDivElement, TabProps>(
|
||||
({ id, active, isBeforeActive, isDragging, onSelect, onClose, onDragStart }, ref) => {
|
||||
const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", id));
|
||||
const name = tabData?.name ?? "...";
|
||||
|
||||
const renderPreview = useCallback((tabData: TabLayoutData) => {
|
||||
console.log("renderPreview", tabData);
|
||||
return <BlockHeader blockId={tabData.blockId} />;
|
||||
}, []);
|
||||
|
||||
const onNodeDelete = useCallback((data: TabLayoutData) => {
|
||||
console.log("onNodeDelete", data);
|
||||
return services.ObjectService.DeleteBlock(data.blockId);
|
||||
}, []);
|
||||
|
||||
if (tabLoading) {
|
||||
return <CenteredLoadingDiv />;
|
||||
}
|
||||
|
||||
if (!tabData) {
|
||||
return (
|
||||
<div className="tabcontent">
|
||||
<CenteredDiv>Tab Not Found</CenteredDiv>
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("tab", { active, isDragging, "before-active": isBeforeActive })}
|
||||
onMouseDown={onDragStart}
|
||||
onClick={onSelect}
|
||||
data-tab-id={id}
|
||||
>
|
||||
<div className="name">{name}</div>
|
||||
{!isDragging && <div className="vertical-line" />}
|
||||
{active && <div className="mask" />}
|
||||
<Button className="secondary ghost close" onClick={onClose}>
|
||||
<i className="fa fa-solid fa-xmark" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="tabcontent">
|
||||
<TileLayout
|
||||
key={tabId}
|
||||
renderContent={renderBlock}
|
||||
renderPreview={renderPreview}
|
||||
layoutTreeStateAtom={layoutStateAtom}
|
||||
onNodeDelete={onNodeDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { TabContent };
|
||||
export { Tab };
|
||||
|
32
frontend/app/tab/tabbar.less
Normal file
32
frontend/app/tab/tabbar.less
Normal file
@ -0,0 +1,32 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.tab-bar-wrapper {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-user-select: none;
|
||||
|
||||
.tab-bar {
|
||||
position: relative; // Needed for absolute positioning of child tabs
|
||||
min-height: 34px; // Adjust as necessary to fit the height of tabs
|
||||
width: calc(100vw - 36px); // 36 is the width of add tab button
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-tab-btn {
|
||||
width: 36px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%); // overridden in js
|
||||
border-radius: 100%;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
height: 32px;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
348
frontend/app/tab/tabbar.tsx
Normal file
348
frontend/app/tab/tabbar.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { deleteLayoutStateAtomForTab } from "@/faraday/lib/layoutAtom";
|
||||
import { atoms } from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import { PrimitiveAtom, atom, useAtom, useAtomValue } from "jotai";
|
||||
import React, { createRef, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { Tab } from "./tab";
|
||||
|
||||
import "./tabbar.less";
|
||||
|
||||
const DEFAULT_TAB_WIDTH = 130;
|
||||
|
||||
// Atoms
|
||||
const tabIdsAtom = atom<string[]>([]);
|
||||
const tabWidthAtom = atom<number>(DEFAULT_TAB_WIDTH);
|
||||
const dragStartPositionsAtom = atom<number[]>([]);
|
||||
const draggingTabAtom = atom<string | null>(null) as PrimitiveAtom<string | null>;
|
||||
const loadingAtom = atom<boolean>(true);
|
||||
|
||||
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 tabBarRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<React.RefObject<HTMLDivElement>[]>([]);
|
||||
const addBtnRef = useRef<HTMLDivElement>(null);
|
||||
const draggingTimeoutId = useRef<NodeJS.Timeout>(null);
|
||||
const draggingRemovedRef = useRef(false);
|
||||
const draggingTabDataRef = useRef({
|
||||
tabId: "",
|
||||
ref: { current: null },
|
||||
tabStartX: 0,
|
||||
tabIndex: 0,
|
||||
dragged: false,
|
||||
});
|
||||
|
||||
const windowData = useAtomValue(atoms.waveWindow);
|
||||
const { activetabid } = windowData;
|
||||
|
||||
let prevDelta: number;
|
||||
let prevDragDirection: string;
|
||||
let shrunk: boolean;
|
||||
|
||||
// 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);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspace, tabIds, setTabIds, setLoading]);
|
||||
|
||||
const updateTabPositions = useCallback(() => {
|
||||
if (tabBarRef.current) {
|
||||
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);
|
||||
}
|
||||
}, [tabRefs.current, setDragStartPositions]);
|
||||
|
||||
const handleResizeTabs = useCallback(() => {
|
||||
const tabBar = tabBarRef.current;
|
||||
if (!tabBar) return;
|
||||
|
||||
const containerWidth = tabBar.getBoundingClientRect().width;
|
||||
const numberOfTabs = tabIds.length;
|
||||
const totalDefaultTabWidth = numberOfTabs * DEFAULT_TAB_WIDTH;
|
||||
let newTabWidth = DEFAULT_TAB_WIDTH;
|
||||
|
||||
if (totalDefaultTabWidth > containerWidth) {
|
||||
newTabWidth = containerWidth / numberOfTabs;
|
||||
shrunk = true;
|
||||
} else {
|
||||
shrunk = false;
|
||||
}
|
||||
|
||||
// Apply the calculated width and position to all tabs
|
||||
tabRefs.current.forEach((ref, index) => {
|
||||
if (ref.current) {
|
||||
ref.current.style.width = `${newTabWidth}px`;
|
||||
ref.current.style.transform = `translateX(${index * newTabWidth}px)`;
|
||||
}
|
||||
});
|
||||
|
||||
// Update the state with the new tab width if it has changed
|
||||
if (newTabWidth !== tabWidth) {
|
||||
setTabWidth(newTabWidth);
|
||||
}
|
||||
|
||||
// Update the position of the Add Tab button if needed
|
||||
const addButton = addBtnRef.current;
|
||||
const lastTabRef = tabRefs.current[tabRefs.current.length - 1];
|
||||
if (addButton && lastTabRef && lastTabRef.current) {
|
||||
const lastTabRect = lastTabRef.current.getBoundingClientRect();
|
||||
addButton.style.position = "absolute";
|
||||
addButton.style.transform = `translateX(${lastTabRect.right}px) translateY(-50%)`;
|
||||
}
|
||||
}, [tabIds, tabWidth, updateTabPositions, setTabWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResizeTabs);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResizeTabs);
|
||||
};
|
||||
}, [handleResizeTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
handleResizeTabs();
|
||||
updateTabPositions();
|
||||
}
|
||||
}, [loading, handleResizeTabs, updateTabPositions]);
|
||||
|
||||
// Make sure timeouts are cleared when component is unmounted
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draggingTimeoutId.current) {
|
||||
clearTimeout(draggingTimeoutId.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";
|
||||
|
||||
let dragDirection;
|
||||
if (currentX - prevDelta > 0) {
|
||||
dragDirection = "+";
|
||||
} else if (currentX - prevDelta === 0) {
|
||||
dragDirection = prevDragDirection;
|
||||
} else {
|
||||
dragDirection = "-";
|
||||
}
|
||||
prevDelta = currentX;
|
||||
prevDragDirection = dragDirection;
|
||||
|
||||
let newTabIndex = tabIndex;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = `translateX(${index * tabWidth}px)`;
|
||||
ref.current.classList.add("animate");
|
||||
}
|
||||
});
|
||||
|
||||
tabIndex = newTabIndex;
|
||||
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 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 = `translateX(${finalLeftPosition}px)`;
|
||||
}
|
||||
|
||||
if (dragged) {
|
||||
draggingTimeoutId.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);
|
||||
}
|
||||
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
draggingRemovedRef.current = false;
|
||||
};
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(name: string, ref: React.RefObject<HTMLDivElement>) => {
|
||||
const tabIndex = tabIds.indexOf(name);
|
||||
const tabStartX = dragStartPositions[tabIndex]; // Starting X position of the tab
|
||||
|
||||
if (ref.current) {
|
||||
draggingTabDataRef.current = {
|
||||
tabId: ref.current.dataset.tabId,
|
||||
ref,
|
||||
tabStartX,
|
||||
tabIndex,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
if (draggingTimeoutId.current) {
|
||||
clearTimeout(draggingTimeoutId.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabIds, dragStartPositions, tabWidth]
|
||||
);
|
||||
|
||||
const handleSelectTab = (tabId: string) => {
|
||||
if (!draggingTabDataRef.current.dragged) {
|
||||
services.ObjectService.SetActiveTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTab = () => {
|
||||
const newTabName = `T${tabIds.length + 1}`;
|
||||
setTabIds([...tabIds, newTabName]);
|
||||
services.ObjectService.AddTabToWorkspace(newTabName, true);
|
||||
};
|
||||
|
||||
const handleCloseTab = (tabId: string) => {
|
||||
services.ObjectService.CloseTab(tabId);
|
||||
deleteLayoutStateAtomForTab(tabId);
|
||||
};
|
||||
|
||||
const isBeforeActive = (tabId: string) => {
|
||||
return tabIds.indexOf(tabId) === tabIds.indexOf(activetabid) - 1;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-bar-wrapper">
|
||||
<div className="tab-bar" ref={tabBarRef}>
|
||||
{tabIds.map((tabId, index) => (
|
||||
<Tab
|
||||
key={tabId}
|
||||
ref={tabRefs.current[index]}
|
||||
id={tabId}
|
||||
onSelect={() => handleSelectTab(tabId)}
|
||||
active={activetabid === tabId}
|
||||
onDragStart={() => handleDragStart(tabId, tabRefs.current[index])}
|
||||
onClose={() => handleCloseTab(tabId)}
|
||||
isBeforeActive={isBeforeActive(tabId)}
|
||||
isDragging={draggingTab === tabId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div ref={addBtnRef} className="add-tab-btn" onClick={handleAddTab}>
|
||||
<i className="fa fa-solid fa-plus fa-fw" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { TabBar };
|
33
frontend/app/tab/tabcontent.less
Normal file
33
frontend/app/tab/tabcontent.less
Normal file
@ -0,0 +1,33 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.tabcontent {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.block-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1 0 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.drag-preview {
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
background-color: aquamarine;
|
||||
color: black;
|
||||
text-align: center;
|
||||
}
|
66
frontend/app/tab/tabcontent.tsx
Normal file
66
frontend/app/tab/tabcontent.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Block, BlockHeader } from "@/app/block/block";
|
||||
import * as services from "@/store/services";
|
||||
import * as WOS from "@/store/wos";
|
||||
|
||||
import { CenteredDiv, CenteredLoadingDiv } from "@/element/quickelems";
|
||||
import { TileLayout } from "@/faraday/index";
|
||||
import { getLayoutStateAtomForTab } from "@/faraday/lib/layoutAtom";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import "./tabcontent.less";
|
||||
|
||||
const TabContent = ({ tabId }: { tabId: string }) => {
|
||||
const oref = useMemo(() => WOS.makeORef("tab", tabId), [tabId]);
|
||||
const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]);
|
||||
const tabLoading = useAtomValue(loadingAtom);
|
||||
const tabAtom = useMemo(() => WOS.getWaveObjectAtom<Tab>(oref), [oref]);
|
||||
const layoutStateAtom = useMemo(() => getLayoutStateAtomForTab(tabId, tabAtom), [tabAtom, tabId]);
|
||||
const tabData = useAtomValue(tabAtom);
|
||||
|
||||
const renderBlock = useCallback((tabData: TabLayoutData, ready: boolean, onClose: () => void) => {
|
||||
// console.log("renderBlock", tabData);
|
||||
if (!tabData.blockId || !ready) {
|
||||
return null;
|
||||
}
|
||||
return <Block blockId={tabData.blockId} onClose={onClose} />;
|
||||
}, []);
|
||||
|
||||
const renderPreview = useCallback((tabData: TabLayoutData) => {
|
||||
console.log("renderPreview", tabData);
|
||||
return <BlockHeader blockId={tabData.blockId} />;
|
||||
}, []);
|
||||
|
||||
const onNodeDelete = useCallback((data: TabLayoutData) => {
|
||||
console.log("onNodeDelete", data);
|
||||
return services.ObjectService.DeleteBlock(data.blockId);
|
||||
}, []);
|
||||
|
||||
if (tabLoading) {
|
||||
return <CenteredLoadingDiv />;
|
||||
}
|
||||
|
||||
if (!tabData) {
|
||||
return (
|
||||
<div className="tabcontent">
|
||||
<CenteredDiv>Tab Not Found</CenteredDiv>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tabcontent">
|
||||
<TileLayout
|
||||
key={tabId}
|
||||
renderContent={renderBlock}
|
||||
renderPreview={renderPreview}
|
||||
layoutTreeStateAtom={layoutStateAtom}
|
||||
onNodeDelete={onNodeDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { TabContent };
|
@ -23,6 +23,8 @@
|
||||
--scrollbar-thumb-color: rgba(255, 255, 255, 0.3);
|
||||
--scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
--tab-green: rgb(88, 193, 66);
|
||||
|
||||
/* z-index values */
|
||||
--zindex-header-hover: 100;
|
||||
}
|
||||
|
@ -39,56 +39,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 32px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
font-weight: bold;
|
||||
border-right: 1px solid var(--border-color);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
background-color: var(--highlight-bg-color);
|
||||
}
|
||||
|
||||
&.active:hover .tab-close {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
position: absolute;
|
||||
display: none;
|
||||
padding: 5px;
|
||||
right: 2px;
|
||||
top: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-add {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
border-left: 1px solid transparent;
|
||||
&:hover {
|
||||
border-left: 1px solid white;
|
||||
background-color: var(--highlight-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,66 +1,19 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { TabContent } from "@/app/tab/tab";
|
||||
import { TabBar } from "@/app/tab/tabbar";
|
||||
import { TabContent } from "@/app/tab/tabcontent";
|
||||
import { atoms } from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import * as WOS from "@/store/wos";
|
||||
import { clsx } from "clsx";
|
||||
import * as jotai from "jotai";
|
||||
import { CenteredDiv } from "../element/quickelems";
|
||||
|
||||
import { LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode } from "@/faraday/index";
|
||||
import {
|
||||
deleteLayoutStateAtomForTab,
|
||||
getLayoutStateAtomForTab,
|
||||
useLayoutTreeStateReducerAtom,
|
||||
} from "@/faraday/lib/layoutAtom";
|
||||
import { getLayoutStateAtomForTab, useLayoutTreeStateReducerAtom } from "@/faraday/lib/layoutAtom";
|
||||
import { useMemo } from "react";
|
||||
import "./workspace.less";
|
||||
|
||||
function Tab({ tabId }: { tabId: string }) {
|
||||
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
||||
const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
|
||||
function setActiveTab() {
|
||||
services.ObjectService.SetActiveTab(tabId);
|
||||
}
|
||||
function handleCloseTab() {
|
||||
services.ObjectService.CloseTab(tabId);
|
||||
deleteLayoutStateAtomForTab(tabId);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={clsx("tab", { active: tabData != null && windowData.activetabid === tabData.oid })}
|
||||
onClick={() => setActiveTab()}
|
||||
>
|
||||
<div className="tab-close" onClick={() => handleCloseTab()}>
|
||||
<div>
|
||||
<i className="fa fa-solid fa-xmark" />
|
||||
</div>
|
||||
</div>
|
||||
{tabData?.name ?? "..."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBar({ workspace }: { workspace: Workspace }) {
|
||||
function handleAddTab() {
|
||||
const newTabName = `Tab-${workspace.tabids.length + 1}`;
|
||||
services.ObjectService.AddTabToWorkspace(newTabName, true);
|
||||
}
|
||||
const tabIds = workspace?.tabids ?? [];
|
||||
return (
|
||||
<div className="tab-bar">
|
||||
{tabIds.map((tabid, idx) => {
|
||||
return <Tab key={idx} tabId={tabid} />;
|
||||
})}
|
||||
<div className="tab-add" onClick={() => handleAddTab()}>
|
||||
<i className="fa fa-solid fa-plus fa-fw" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Widgets() {
|
||||
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
||||
const activeTabAtom = useMemo(() => {
|
||||
@ -149,6 +102,7 @@ function WorkspaceElem() {
|
||||
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
||||
const activeTabId = windowData?.activetabid;
|
||||
const ws = jotai.useAtomValue(atoms.workspace);
|
||||
console.log("ws", ws);
|
||||
return (
|
||||
<div className="workspace">
|
||||
<TabBar workspace={ws} />
|
||||
|
22
frontend/types/gotypes.d.ts
vendored
22
frontend/types/gotypes.d.ts
vendored
@ -4,7 +4,6 @@
|
||||
// generated by cmd/generate/main-generate.go
|
||||
|
||||
declare global {
|
||||
|
||||
// wstore.Block
|
||||
type Block = WaveObj & {
|
||||
blockdef: BlockDef;
|
||||
@ -30,13 +29,21 @@ declare global {
|
||||
|
||||
type BlockCommand = {
|
||||
command: string;
|
||||
} & ( BlockSetMetaCommand | BlockGetMetaCommand | BlockMessageCommand | BlockAppendFileCommand | BlockAppendIJsonCommand | BlockInputCommand | BlockSetViewCommand );
|
||||
} & (
|
||||
| BlockSetMetaCommand
|
||||
| BlockGetMetaCommand
|
||||
| BlockMessageCommand
|
||||
| BlockAppendFileCommand
|
||||
| BlockAppendIJsonCommand
|
||||
| BlockInputCommand
|
||||
| BlockSetViewCommand
|
||||
);
|
||||
|
||||
// wstore.BlockDef
|
||||
type BlockDef = {
|
||||
controller?: string;
|
||||
view?: string;
|
||||
files?: {[key: string]: FileDef};
|
||||
files?: { [key: string]: FileDef };
|
||||
meta?: MetaType;
|
||||
};
|
||||
|
||||
@ -111,7 +118,7 @@ declare global {
|
||||
meta?: MetaType;
|
||||
};
|
||||
|
||||
type MetaType = {[key: string]: any}
|
||||
type MetaType = { [key: string]: any };
|
||||
|
||||
// tsgenmeta.MethodMeta
|
||||
type MethodMeta = {
|
||||
@ -167,7 +174,7 @@ declare global {
|
||||
|
||||
type WSCommandType = {
|
||||
wscommand: string;
|
||||
} & ( SetBlockTermSizeWSCommand );
|
||||
} & SetBlockTermSizeWSCommand;
|
||||
|
||||
// eventbus.WSEventType
|
||||
type WSEventType = {
|
||||
@ -226,7 +233,7 @@ declare global {
|
||||
workspaceid: string;
|
||||
activetabid: string;
|
||||
activeblockid?: string;
|
||||
activeblockmap: {[key: string]: string};
|
||||
activeblockmap: { [key: string]: string };
|
||||
pos: Point;
|
||||
winsize: WinSize;
|
||||
lastfocusts: number;
|
||||
@ -239,7 +246,6 @@ declare global {
|
||||
tabids: string[];
|
||||
meta: MetaType;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export {}
|
||||
export {};
|
||||
|
@ -99,6 +99,23 @@ func (svc *ObjectService) AddTabToWorkspace(uiContext wstore.UIContext, tabName
|
||||
return tab.OID, wstore.ContextGetUpdatesRtn(ctx), nil
|
||||
}
|
||||
|
||||
func (svc *ObjectService) UpdateWorkspaceTabIds_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "workspaceId", "tabIds"},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *ObjectService) UpdateWorkspaceTabIds(uiContext wstore.UIContext, workspaceId string, tabIds []string) (wstore.UpdatesRtnType, error) {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = wstore.ContextWithUpdates(ctx)
|
||||
err := wstore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating workspace tab ids: %w", err)
|
||||
}
|
||||
return wstore.ContextGetUpdatesRtn(ctx), nil
|
||||
}
|
||||
|
||||
func (svc *ObjectService) SetActiveTab_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "tabId"},
|
||||
|
@ -181,6 +181,18 @@ func CreateWorkspace(ctx context.Context) (*Workspace, error) {
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error {
|
||||
return WithTx(ctx, func(tx *TxWrap) error {
|
||||
ws, _ := DBGet[*Workspace](tx.Context(), workspaceId)
|
||||
if ws == nil {
|
||||
return fmt.Errorf("workspace not found: %q", workspaceId)
|
||||
}
|
||||
ws.TabIds = tabIds
|
||||
DBUpdate(tx.Context(), ws)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func SetActiveTab(ctx context.Context, windowId string, tabId string) error {
|
||||
return WithTx(ctx, func(tx *TxWrap) error {
|
||||
window, _ := DBGet[*Window](tx.Context(), windowId)
|
||||
|
Loading…
Reference in New Issue
Block a user