DnD tabs (#44)

This commit is contained in:
Red J Adaya 2024-06-18 12:50:33 +08:00 committed by GitHub
parent 9adecebbf2
commit b6c85e38f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 705 additions and 263 deletions

View File

@ -1,8 +1,8 @@
// Copyright 2024, Command Line Inc. /* Copyright 2024, Command Line Inc. */
// SPDX-License-Identifier: Apache-2.0 /* SPDX-License-Identifier: Apache-2.0 */
.wave-button { .button {
background: none; background: var(--accent-color);
border: none; border: none;
cursor: pointer; cursor: pointer;
outline: inherit; outline: inherit;
@ -18,14 +18,16 @@
-webkit-user-select: none; -webkit-user-select: none;
color: var(--main-text-color); color: var(--main-text-color);
background: var(--accent-color);
i { i {
fill: var(--main-text-color); fill: var(--main-text-color);
} }
&.primary { &.primary,
&.secondary {
color: var(--main-text-color); color: var(--main-text-color);
background: var(--accent-color); background: var(--accent-color);
i { i {
fill: var(--main-text-color); fill: var(--main-text-color);
} }
@ -35,7 +37,8 @@
background: var(--error-color); background: var(--error-color);
} }
&.primary.outlined { &.primary.outlined,
&.primary.greyoutlined {
background: none; background: none;
border: 1px solid var(--accent-color); border: 1px solid var(--accent-color);
@ -45,56 +48,50 @@
} }
&.primary.greyoutlined { &.primary.greyoutlined {
background: none; border-color: var(--secondary-text-color);
border: 1px solid var(--secondary-text-color);
i { i {
fill: var(--secondary-text-color); fill: var(--secondary-text-color);
} }
} }
&.primary.outlined.danger {
border-color: var(--error-color);
i {
fill: var(--error-color);
}
}
&.primary.outlined, &.primary.outlined,
&.primary.greyoutlined { &.primary.greyoutlined {
&.hover-danger:hover { &.hover-danger:hover {
color: var(--main-text-color); color: var(--main-text-color);
border: 1px solid var(--error-color); border-color: var(--error-color);
background: var(--error-color); background: var(--error-color);
} }
} }
&.primary.outlined.danger {
background: none;
border: 1px solid var(--error-color);
i {
fill: var(--error-color);
}
}
&.greytext { &.greytext {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
&.primary.ghost { &.primary.ghost {
background: none; background: none;
i { i {
fill: var(--accent-color); fill: var(--accent-color);
} }
} }
&.primary.ghost.danger { &.primary.ghost.danger {
background: none;
i { i {
fill: var(--app-error-color); fill: var(--app-error-color);
} }
} }
&.secondary { &.secondary {
color: var(--main-text-color);
background: var(--highlight-bg-color); background: var(--highlight-bg-color);
i {
fill: var(--main-text-color);
}
} }
&.secondary.outlined { &.secondary.outlined {
@ -103,8 +100,7 @@
} }
&.secondary.outlined.danger { &.secondary.outlined.danger {
background: none; border-color: var(--error-color);
border: 1px solid var(--error-color);
} }
&.secondary.ghost { &.secondary.ghost {

View File

@ -1,55 +1,28 @@
// Copyright 2024, Command Line Inc. import clsx from "clsx";
// SPDX-License-Identifier: Apache-2.0 import React from "react";
import { clsx } from "clsx";
import * as React from "react";
import "./button.less"; import "./button.less";
interface ButtonProps { interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
style?: React.CSSProperties;
autoFocus?: boolean;
className?: string; className?: string;
termInline?: boolean;
title?: string;
} }
class Button extends React.Component<ButtonProps> { const Button: React.FC<ButtonProps> = ({ className = "primary", children, disabled, ...props }) => {
static defaultProps = { const hasIcon = React.Children.toArray(children).some(
style: {}, (child) => React.isValidElement(child) && (child as React.ReactElement).type === "svg"
className: "primary", );
};
handleClick(e) { return (
if (this.props.onClick && !this.props.disabled) { <button
this.props.onClick(e); className={clsx("button", className, {
} disabled,
} hasIcon,
})}
render() { disabled={disabled}
const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className, title } = this.props; {...props}
>
return ( {children}
<button </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>
);
}
}
export { Button }; export { Button };
export type { ButtonProps };

View File

@ -94,6 +94,11 @@ class ObjectServiceType {
UpdateObjectMeta(oref: string, meta: MetaType): Promise<void> { UpdateObjectMeta(oref: string, meta: MetaType): Promise<void> {
return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments)) 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() export const ObjectService = new ObjectServiceType()

View File

@ -1,33 +1,105 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
.tabcontent { .tab {
display: flex; cursor: pointer;
flex-direction: row; width: 130px;
flex-grow: 1; height: 100%;
min-height: 0; position: absolute;
width: 100%; box-sizing: border-box;
align-items: center; cursor: pointer;
justify-content: center; font-weight: bold;
overflow: hidden; 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; display: flex;
flex-direction: row; align-items: center;
flex: 1 0 0; justify-content: center;
height: 100%; cursor: pointer;
overflow: hidden; top: 50%;
border: 1px solid var(--border-color); z-index: 3;
border-radius: 4px; 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;
}

View File

@ -1,66 +1,45 @@
// Copyright 2023, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { Block, BlockHeader } from "@/app/block/block"; import { Button } from "@/element/button";
import * as services from "@/store/services";
import * as WOS from "@/store/wos"; 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"; import "./tab.less";
const TabContent = ({ tabId }: { tabId: string }) => { interface TabProps {
const oref = useMemo(() => WOS.makeORef("tab", tabId), [tabId]); id: string;
const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]); active: boolean;
const tabLoading = useAtomValue(loadingAtom); isBeforeActive: boolean;
const tabAtom = useMemo(() => WOS.getWaveObjectAtom<Tab>(oref), [oref]); isDragging: boolean;
const layoutStateAtom = useMemo(() => getLayoutStateAtomForTab(tabId, tabAtom), [tabAtom, tabId]); onSelect: () => void;
const tabData = useAtomValue(tabAtom); onClose: () => void;
onDragStart: () => void;
}
const renderBlock = useCallback((tabData: TabLayoutData, ready: boolean, onClose: () => void) => { const Tab = React.forwardRef<HTMLDivElement, TabProps>(
// console.log("renderBlock", tabData); ({ id, active, isBeforeActive, isDragging, onSelect, onClose, onDragStart }, ref) => {
if (!tabData.blockId || !ready) { const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", id));
return null; const name = tabData?.name ?? "...";
}
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 ( return (
<div className="tabcontent"> <div
<CenteredDiv>Tab Not Found</CenteredDiv> 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> </div>
); );
} }
);
return ( export { Tab };
<div className="tabcontent">
<TileLayout
key={tabId}
renderContent={renderBlock}
renderPreview={renderPreview}
layoutTreeStateAtom={layoutStateAtom}
onNodeDelete={onNodeDelete}
/>
</div>
);
};
export { TabContent };

View 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
View 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 };

View 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;
}

View 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 };

View File

@ -23,6 +23,8 @@
--scrollbar-thumb-color: rgba(255, 255, 255, 0.3); --scrollbar-thumb-color: rgba(255, 255, 255, 0.3);
--scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5); --scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5);
--tab-green: rgb(88, 193, 66);
/* z-index values */ /* z-index values */
--zindex-header-hover: 100; --zindex-header-hover: 100;
} }

View File

@ -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);
}
}
}

View File

@ -1,66 +1,19 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // 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 { atoms } from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as WOS from "@/store/wos"; import * as WOS from "@/store/wos";
import { clsx } from "clsx";
import * as jotai from "jotai"; import * as jotai from "jotai";
import { CenteredDiv } from "../element/quickelems"; import { CenteredDiv } from "../element/quickelems";
import { LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode } from "@/faraday/index"; import { LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode } from "@/faraday/index";
import { import { getLayoutStateAtomForTab, useLayoutTreeStateReducerAtom } from "@/faraday/lib/layoutAtom";
deleteLayoutStateAtomForTab,
getLayoutStateAtomForTab,
useLayoutTreeStateReducerAtom,
} from "@/faraday/lib/layoutAtom";
import { useMemo } from "react"; import { useMemo } from "react";
import "./workspace.less"; 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() { function Widgets() {
const windowData = jotai.useAtomValue(atoms.waveWindow); const windowData = jotai.useAtomValue(atoms.waveWindow);
const activeTabAtom = useMemo(() => { const activeTabAtom = useMemo(() => {
@ -149,6 +102,7 @@ function WorkspaceElem() {
const windowData = jotai.useAtomValue(atoms.waveWindow); const windowData = jotai.useAtomValue(atoms.waveWindow);
const activeTabId = windowData?.activetabid; const activeTabId = windowData?.activetabid;
const ws = jotai.useAtomValue(atoms.workspace); const ws = jotai.useAtomValue(atoms.workspace);
console.log("ws", ws);
return ( return (
<div className="workspace"> <div className="workspace">
<TabBar workspace={ws} /> <TabBar workspace={ws} />

View File

@ -4,7 +4,6 @@
// generated by cmd/generate/main-generate.go // generated by cmd/generate/main-generate.go
declare global { declare global {
// wstore.Block // wstore.Block
type Block = WaveObj & { type Block = WaveObj & {
blockdef: BlockDef; blockdef: BlockDef;
@ -30,13 +29,21 @@ declare global {
type BlockCommand = { type BlockCommand = {
command: string; command: string;
} & ( BlockSetMetaCommand | BlockGetMetaCommand | BlockMessageCommand | BlockAppendFileCommand | BlockAppendIJsonCommand | BlockInputCommand | BlockSetViewCommand ); } & (
| BlockSetMetaCommand
| BlockGetMetaCommand
| BlockMessageCommand
| BlockAppendFileCommand
| BlockAppendIJsonCommand
| BlockInputCommand
| BlockSetViewCommand
);
// wstore.BlockDef // wstore.BlockDef
type BlockDef = { type BlockDef = {
controller?: string; controller?: string;
view?: string; view?: string;
files?: {[key: string]: FileDef}; files?: { [key: string]: FileDef };
meta?: MetaType; meta?: MetaType;
}; };
@ -111,7 +118,7 @@ declare global {
meta?: MetaType; meta?: MetaType;
}; };
type MetaType = {[key: string]: any} type MetaType = { [key: string]: any };
// tsgenmeta.MethodMeta // tsgenmeta.MethodMeta
type MethodMeta = { type MethodMeta = {
@ -167,7 +174,7 @@ declare global {
type WSCommandType = { type WSCommandType = {
wscommand: string; wscommand: string;
} & ( SetBlockTermSizeWSCommand ); } & SetBlockTermSizeWSCommand;
// eventbus.WSEventType // eventbus.WSEventType
type WSEventType = { type WSEventType = {
@ -226,7 +233,7 @@ declare global {
workspaceid: string; workspaceid: string;
activetabid: string; activetabid: string;
activeblockid?: string; activeblockid?: string;
activeblockmap: {[key: string]: string}; activeblockmap: { [key: string]: string };
pos: Point; pos: Point;
winsize: WinSize; winsize: WinSize;
lastfocusts: number; lastfocusts: number;
@ -239,7 +246,6 @@ declare global {
tabids: string[]; tabids: string[];
meta: MetaType; meta: MetaType;
}; };
} }
export {} export {};

View File

@ -99,6 +99,23 @@ func (svc *ObjectService) AddTabToWorkspace(uiContext wstore.UIContext, tabName
return tab.OID, wstore.ContextGetUpdatesRtn(ctx), nil 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 { func (svc *ObjectService) SetActiveTab_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{ return tsgenmeta.MethodMeta{
ArgNames: []string{"uiContext", "tabId"}, ArgNames: []string{"uiContext", "tabId"},

View File

@ -181,6 +181,18 @@ func CreateWorkspace(ctx context.Context) (*Workspace, error) {
return ws, nil 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 { func SetActiveTab(ctx context.Context, windowId string, tabId string) error {
return WithTx(ctx, func(tx *TxWrap) error { return WithTx(ctx, func(tx *TxWrap) error {
window, _ := DBGet[*Window](tx.Context(), windowId) window, _ := DBGet[*Window](tx.Context(), windowId)