diff --git a/demo/public/layouts/complex.layout b/demo/public/layouts/complex.layout index f0082502..f831d2ab 100644 --- a/demo/public/layouts/complex.layout +++ b/demo/public/layouts/complex.layout @@ -1,6 +1,6 @@ { "global": { - "tabEnableFloat": true, + "enableFloat": true, "tabSetEnableTabScrollbar": true, "borderEnableTabScrollbar": true }, diff --git a/demo/public/layouts/default.layout b/demo/public/layouts/default.layout index 3c3f1e72..602e1919 100644 --- a/demo/public/layouts/default.layout +++ b/demo/public/layouts/default.layout @@ -19,6 +19,7 @@ "id": "#0ae8e0fb-dba2-4b14-9d75-08781231479a", "name": "Output", "component": "grid", + "tabEnableFloat": "true", "enableClose": false, "icon": "images/bar_chart.svg" }, @@ -172,6 +173,7 @@ "type": "tab", "id": "#0e23b4b3-498a-4625-a916-b1e6e19eaf3f", "name": "Wikipedia", + "enableFloat": true, "component": "multitype", "config": { "type": "url", @@ -182,6 +184,7 @@ "type": "tab", "id": "#31b3af95-2fc9-4511-8d5d-1e6255b92eae", "name": "MUI", + "enableFloat": true, "enablePopout": false, "component": "mui" } @@ -196,6 +199,7 @@ "type": "tab", "id": "#4784d2d4-24a4-4ef2-ac6e-7a3ea7b03ba3", "name": "MUI Grid", + "enableFloat": true, "enablePopout": false, "component": "muigrid" } diff --git a/demo/public/layouts/sub.layout b/demo/public/layouts/sub.layout index df8b8f45..f4b4cdb1 100644 --- a/demo/public/layouts/sub.layout +++ b/demo/public/layouts/sub.layout @@ -1,6 +1,6 @@ { "global": { - "tabEnableFloat": true + "enableFloat": true }, "borders": [], "layout": { diff --git a/demo/public/layouts/test_two_tabs.layout b/demo/public/layouts/test_two_tabs.layout index 86260d64..eea556c3 100644 --- a/demo/public/layouts/test_two_tabs.layout +++ b/demo/public/layouts/test_two_tabs.layout @@ -12,7 +12,8 @@ { "type": "tab", "name": "One", - "component": "testing" + "component": "testing", + "enableFloat": true } ] }, diff --git a/src/Types.ts b/src/Types.ts index cedd42cb..8f6dc716 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -39,6 +39,12 @@ export enum CLASSES { FLEXLAYOUT__ERROR_BOUNDARY_CONTENT = "flexlayout__error_boundary_content", FLEXLAYOUT__FLOATING_WINDOW_CONTENT = "flexlayout__floating_window_content", + FLEXLAYOUT__FLOATING_TAB = "flexlayout__floating_tab", + FLEXLAYOUT__FLOATING_TAB_HEADER = "flexlayout__floating_tab_header", + FLEXLAYOUT__FLOATING_TAB_TITLE = "flexlayout__floating_tab_title", + FLEXLAYOUT__FLOATING_TAB_CLOSE = "flexlayout__floating_tab_close", + FLEXLAYOUT__FLOATING_TAB_DOCK = "flexlayout__floating_tab_dock", + FLEXLAYOUT__FLOATING_TAB_CONTENT = "flexlayout__floating_tab_content", FLEXLAYOUT__LAYOUT = "flexlayout__layout", FLEXLAYOUT__LAYOUT_MOVEABLES = "flexlayout__layout_moveables", diff --git a/src/model/Actions.ts b/src/model/Actions.ts index 506ff840..1b40695e 100755 --- a/src/model/Actions.ts +++ b/src/model/Actions.ts @@ -22,6 +22,8 @@ export class Actions { static POPOUT_TABSET = "FlexLayout_PopoutTabset"; static CLOSE_WINDOW = "FlexLayout_CloseWindow"; static CREATE_WINDOW = "FlexLayout_CreateWindow"; + static FLOAT_TAB = "FlexLayout_FloatTab"; + static UNFLOAT_TAB = "FlexLayout_UnFloatTab"; /** * Adds a tab node to the given tabset node @@ -158,6 +160,24 @@ export class Actions { return new Action(Actions.POPOUT_TAB, { node: nodeId }); } + /** + * + * @param nodeId + * @returns {Action} the action + */ + static floatTab(nodeId: string): Action { + return new Action(Actions.FLOAT_TAB, { node: nodeId }); + } + + /** + * Docks a floating tab back to the layout + * @param floatingId the id of the floating tab to dock + * @returns {Action} the action + */ + static unfloatTab(floatingId: string): Action { + return new Action(Actions.UNFLOAT_TAB, { floatingId }); + } + /** * Pops out the given tab set node into a new browser window * @param nodeId the tab set node to popout diff --git a/src/model/IJsonModel.ts b/src/model/IJsonModel.ts index 9545c4b6..cc4456cf 100755 --- a/src/model/IJsonModel.ts +++ b/src/model/IJsonModel.ts @@ -7,6 +7,7 @@ export interface IJsonModel { borders?: IJsonBorderNode[]; layout: IJsonRowNode; // top level 'row' is horizontal, rows inside rows take opposite orientation to parent row (ie can act as columns) popouts?: Record; + floatings?: Record; } export interface IJsonRect { @@ -21,6 +22,14 @@ export interface IJsonPopout { rect: IJsonRect ; } +export interface IJsonFloating { + tabId: string; + rect: IJsonRect; + zIndex: number; + originalParentId: string; + originalIndex: number; +} + export interface IJsonBorderNode extends IBorderAttributes { location: IBorderLocation; children: IJsonTabNode[]; @@ -249,6 +258,15 @@ export interface IGlobalAttributes { */ tabEnablePopout?: boolean; + /** + Value for TabNode attribute enableFloat if not overridden + + enable floating tab feature + + Default: false + */ + tabEnableFloat?: boolean; + /** Value for TabNode attribute enablePopoutIcon if not overridden @@ -765,6 +783,13 @@ export interface ITabAttributes { */ enablePopout?: boolean; + /** + enable floating tab feature + + Default: inherited from Global attribute tabEnableFloat (default false) + */ + enableFloat?: boolean; + /** whether to show the popout icon in the tabset header if this tab enables popouts @@ -865,12 +890,40 @@ export interface ITabAttributes { tabsetClassName?: string; /** - + Fixed value: "tab" */ type?: string; + /** + x position of tab when floating or after dock + + Default: undefined + */ + x?: number; + + /** + y position of tab when floating or after dock + + Default: undefined + */ + y?: number; + + /** + width of tab when floating + + Default: undefined + */ + floatingWidth?: number; + + /** + height of tab when floating + + Default: undefined + */ + floatingHeight?: number; + } export interface IBorderAttributes { /** diff --git a/src/model/Model.ts b/src/model/Model.ts index f730b672..a2f0ec03 100755 --- a/src/model/Model.ts +++ b/src/model/Model.ts @@ -9,7 +9,7 @@ import { BorderNode } from "./BorderNode"; import { BorderSet } from "./BorderSet"; import { IDraggable } from "./IDraggable"; import { IDropTarget } from "./IDropTarget"; -import { IJsonModel, IJsonPopout, ITabSetAttributes } from "./IJsonModel"; +import { IJsonModel, IJsonPopout, IJsonFloating, ITabSetAttributes } from "./IJsonModel"; import { Node } from "./Node"; import { RowNode } from "./RowNode"; import { TabNode } from "./TabNode"; @@ -47,6 +47,10 @@ export class Model { private windows: Map; /** @internal */ private rootWindow: LayoutWindow; + /** @internal */ + private floatings: Map; + /** @internal */ + private floatingNodes: Map; /** * 'private' constructor. Use the static method Model.fromJson(json) to create a model @@ -59,6 +63,8 @@ export class Model { this.windows = new Map(); this.rootWindow = new LayoutWindow(Model.MAIN_WINDOW_ID, Rect.empty()); this.windows.set(Model.MAIN_WINDOW_ID, this.rootWindow); + this.floatings = new Map(); + this.floatingNodes = new Map(); this.changeListeners = []; } @@ -203,6 +209,111 @@ export class Model { returnVal = windowId; break; } + case Actions.FLOAT_TAB: { + const node = this.idMap.get(action.data.node); + if (node instanceof TabNode) { + let rect = Rect.empty(); + + // Use saved position and size if available + const savedX = node.getX(); + const savedY = node.getY(); + const savedWidth = node.getFloatingWidth(); + const savedHeight = node.getFloatingHeight(); + + if (savedX !== undefined && savedY !== undefined) { + // Use saved position and size from previous float + if (node.getParent() instanceof TabSetNode) { + rect = node.getParent()!.getRect(); + } else if (node.getParent() instanceof BorderNode) { + rect = (node.getParent() as BorderNode).getContentRect(); + } + // Use saved size if available, otherwise use parent rect size + const width = savedWidth !== undefined ? savedWidth : rect.width; + const height = savedHeight !== undefined ? savedHeight : rect.height; + rect = new Rect(savedX, savedY, width, height); + } else { + // Use parent rect for first time float + if (node.getParent() instanceof TabSetNode) { + rect = node.getParent()!.getRect(); + } else if (node.getParent() instanceof BorderNode) { + rect = (node.getParent() as BorderNode).getContentRect(); + } + } + + const parent = node.getParent(); + let originalParentId = ""; + let originalIndex = -1; + + if (parent) { + originalParentId = parent.getId(); + originalIndex = parent.getChildren().indexOf(node); + if (parent instanceof TabSetNode || parent instanceof BorderNode) { + parent.remove(node); + } + (node as any).parent = null; + } + + const floatingId = randomUUID(); + const floating: IJsonFloating = { + tabId: node.getId(), + rect: rect.toJson(), + zIndex: this.getNextZIndex(), + originalParentId, + originalIndex + }; + + this.floatings.set(floatingId, floating); + this.floatingNodes.set(node.getId(), node); + } + break; + } + case Actions.UNFLOAT_TAB: { + const floatingId = action.data.floatingId; + const floating = this.floatings.get(floatingId); + if (floating) { + const tabNode = this.idMap.get(floating.tabId); + if (tabNode instanceof TabNode) { + // Save current floating position and size to tab for next float + tabNode.setX(floating.rect.x); + tabNode.setY(floating.rect.y); + tabNode.setFloatingWidth(floating.rect.width); + tabNode.setFloatingHeight(floating.rect.height); + + // Remove from floating + this.floatings.delete(floatingId); + this.floatingNodes.delete(floating.tabId); + + // Get original parent + const originalParent = this.idMap.get(floating.originalParentId); + + if (originalParent && 'children' in originalParent && Array.isArray((originalParent as any).children)) { + // Add back to original parent at original position + const children = (originalParent as any).children; + const insertIndex = Math.min(floating.originalIndex, children.length); + children.splice(insertIndex, 0, tabNode); + (tabNode as any).parent = originalParent; + + // Select the restored tab + if (typeof (originalParent as any).setSelected === 'function') { + (originalParent as any).setSelected(insertIndex); + } + } else { + // Fallback: add to first available tabset + const firstTabSet = this.getFirstTabSet(); + if (firstTabSet && 'children' in firstTabSet) { + const children = (firstTabSet as any).children; + children.push(tabNode); + (tabNode as any).parent = firstTabSet; + + if (typeof (firstTabSet as any).setSelected === 'function') { + (firstTabSet as any).setSelected(children.length - 1); + } + } + } + } + } + break; + } case Actions.RENAME_TAB: { const node = this.idMap.get(action.data.node); if (node instanceof TabNode) { @@ -349,6 +460,40 @@ export class Model { return this.windows; } + /** + * Gets the floatings map + * @returns {Map} + */ + getFloatingsMap() { + return this.floatings; + } + + /** + * Removes a floating tab by id + * @param floatingId + */ + removeFloating(floatingId: string) { + const floating = this.floatings.get(floatingId); + if (floating) { + this.floatingNodes.delete(floating.tabId); + this.floatings.delete(floatingId); + } + } + + /** + * Gets the next z-index for floating tabs + * @returns {number} + */ + getNextZIndex(): number { + let maxZ = 1000; + for (const floating of this.floatings.values()) { + if (floating.zIndex > maxZ) { + maxZ = floating.zIndex; + } + } + return maxZ + 1; + } + /** * Visits all the nodes in the model and calls the given function for each * @param fn a function that takes visited node and a integer level as parameters @@ -411,6 +556,23 @@ export class Model { model.windows.set(windowId, layoutWindow); } } + if (json.floatings) { + for (const floatingId in json.floatings) { + const floatingJson = json.floatings[floatingId]; + // Create TabNode for floating tab but don't add to tree + const tabNode = TabNode.fromJson(floatingJson, model, true); + // Set parent to null to mark as floating + (tabNode as any).parent = null; + // Store the floating reference + model.floatings.set(floatingId, { + tabId: tabNode.getId(), + rect: floatingJson.rect, + zIndex: floatingJson.zIndex, + originalParentId: floatingJson.originalParentId || "", + originalIndex: floatingJson.originalIndex || -1 + }); + } + } model.rootWindow.root = RowNode.fromJson(json.layout, model, model.getwindowsMap().get(Model.MAIN_WINDOW_ID)!); model.tidy(); // initial tidy of node tree @@ -437,11 +599,26 @@ export class Model { } } + const floatings: Record = {}; + for (const [id, floating] of this.floatings) { + const tabNode = this.idMap.get(floating.tabId); + if (tabNode instanceof TabNode) { + floatings[id] = { + ...tabNode.toJson(), + rect: floating.rect, + zIndex: floating.zIndex, + originalParentId: floating.originalParentId, + originalIndex: floating.originalIndex + }; + } + } + return { global, borders: this.borders.toJson(), layout: this.rootWindow.root!.toJson(), - popouts: windows + popouts: windows, + floatings: floatings }; } @@ -551,6 +728,11 @@ export class Model { // node.normalizeWeights(); // } }); + + // Also add floating tab nodes to idMap + for (const [tabId, tabNode] of this.floatingNodes) { + this.idMap.set(tabId, tabNode); + } // console.log(JSON.stringify(Object.keys(this._idMap))); } @@ -653,6 +835,7 @@ export class Model { attributeDefinitions.add("tabEnablePopout", false).setType(Attribute.BOOLEAN).setAlias("tabEnableFloat"); attributeDefinitions.add("tabEnablePopoutIcon", true).setType(Attribute.BOOLEAN); attributeDefinitions.add("tabEnablePopoutOverlay", false).setType(Attribute.BOOLEAN); + attributeDefinitions.add("tabEnableFloat", false).setType(Attribute.BOOLEAN); attributeDefinitions.add("tabEnableDrag", true).setType(Attribute.BOOLEAN); attributeDefinitions.add("tabEnableRename", true).setType(Attribute.BOOLEAN); attributeDefinitions.add("tabContentClassName", undefined).setType(Attribute.STRING); diff --git a/src/model/TabNode.ts b/src/model/TabNode.ts index c9d38450..9e499dc7 100755 --- a/src/model/TabNode.ts +++ b/src/model/TabNode.ts @@ -130,6 +130,10 @@ export class TabNode extends Node implements IDraggable { return this.getAttr("enablePopoutOverlay") as boolean; } + isEnableFloat() { + return this.getAttr("enableFloat") as boolean; + } + isEnableDrag() { return this.getAttr("enableDrag") as boolean; } @@ -301,6 +305,42 @@ export class TabNode extends Node implements IDraggable { this.attributes.name = name; } + getX() { + return this.getAttr("x") as number | undefined; + } + + getY() { + return this.getAttr("y") as number | undefined; + } + + getFloatingWidth() { + return this.getAttr("floatingWidth") as number | undefined; + } + + getFloatingHeight() { + return this.getAttr("floatingHeight") as number | undefined; + } + + /** @internal */ + setX(x: number | undefined) { + this.attributes.x = x; + } + + /** @internal */ + setY(y: number | undefined) { + this.attributes.y = y; + } + + /** @internal */ + setFloatingWidth(width: number | undefined) { + this.attributes.floatingWidth = width; + } + + /** @internal */ + setFloatingHeight(height: number | undefined) { + this.attributes.floatingHeight = height; + } + /** @internal */ delete() { (this.parent as TabSetNode | BorderNode).remove(this); @@ -399,6 +439,9 @@ export class TabNode extends Node implements IDraggable { `if this tab will not work correctly in a popout window when the main window is backgrounded (inactive) then enabling this option will gray out this tab` ); + attributeDefinitions.addInherited("enableFloat", "tabEnableFloat").setType(Attribute.BOOLEAN).setDescription( + `enable floating tab feature` + ); attributeDefinitions.addInherited("borderWidth", "tabBorderWidth").setType(Attribute.NUMBER).setDescription( `width when added to border, -1 will use border size` @@ -419,6 +462,19 @@ export class TabNode extends Node implements IDraggable { `the max height of this tab` ); + attributeDefinitions.add("x", undefined).setType(Attribute.NUMBER).setDescription( + `x position of tab when floating or after dock` + ); + attributeDefinitions.add("y", undefined).setType(Attribute.NUMBER).setDescription( + `y position of tab when floating or after dock` + ); + attributeDefinitions.add("floatingWidth", undefined).setType(Attribute.NUMBER).setDescription( + `width of tab when floating` + ); + attributeDefinitions.add("floatingHeight", undefined).setType(Attribute.NUMBER).setDescription( + `height of tab when floating` + ); + return attributeDefinitions; } } diff --git a/src/view/FloatingTab.tsx b/src/view/FloatingTab.tsx new file mode 100644 index 00000000..f3076b3c --- /dev/null +++ b/src/view/FloatingTab.tsx @@ -0,0 +1,196 @@ +import * as React from "react"; +import { CLASSES } from "../Types"; +import { LayoutInternal } from "./Layout"; +import { IJsonFloating } from "../model/IJsonModel"; +import { Rect } from "../Rect"; + +/** @internal */ +export interface IFloatingTabProps { + floating: IJsonFloating; + floatingId: string; + layout: LayoutInternal; + tabNode: any; // The actual TabNode instance + onCloseFloating: (floatingId: string) => void; + onDockFloating: (floatingId: string, x: number, y: number) => void; + onUpdateFloating: (floatingId: string, rect: Rect, zIndex: number) => void; +} + +/** @internal */ +export const FloatingTab = (props: React.PropsWithChildren) => { + const { floating, floatingId, layout, tabNode, onCloseFloating, onDockFloating, onUpdateFloating, children } = props; + const [isDragging, setIsDragging] = React.useState(false); + const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 }); + const [position, setPosition] = React.useState({ x: floating.rect.x, y: floating.rect.y }); + const [size, setSize] = React.useState({ width: floating.rect.width, height: floating.rect.height }); + const [zIndex, setZIndex] = React.useState(floating.zIndex); + const floatingRef = React.useRef(null); + const headerRef = React.useRef(null); + + const handleMouseDown = (e: React.MouseEvent) => { + // Allow dragging only from header area + if (headerRef.current && headerRef.current.contains(e.target as Node)) { + const target = e.target as HTMLElement; + // Don't start dragging if clicking on buttons + if (!target.closest('button')) { + setIsDragging(true); + setDragOffset({ + x: e.clientX - position.x, + y: e.clientY - position.y + }); + // Bring to front + const newZ = layout.getModel().getNextZIndex(); + setZIndex(newZ); + } + } + }; + + const handleMouseMove = React.useCallback((e: MouseEvent) => { + if (isDragging) { + const newX = e.clientX - dragOffset.x; + const newY = e.clientY - dragOffset.y; + setPosition({ x: newX, y: newY }); + } + }, [isDragging, dragOffset]); + + const handleMouseUp = React.useCallback((e: MouseEvent) => { + if (isDragging) { + setIsDragging(false); + + // Update the floating position in the model + const rect = new Rect(position.x, position.y, size.width, size.height); + onUpdateFloating(floatingId, rect, zIndex); + } + }, [isDragging, position, size, zIndex, floatingId, onUpdateFloating]); + + React.useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + console.log("Close button clicked!"); + onCloseFloating(floatingId); + }; + + const handleDock = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + console.log("Dock button clicked!"); + const centerX = window.innerWidth / 2; + const centerY = window.innerHeight / 2; + onDockFloating(floatingId, centerX, centerY); + }; + + const cm = layout.getClassName; + const icons = layout.getIcons(); + + const style: React.CSSProperties = { + left: position.x, + top: position.y, + width: size.width, + height: size.height, + zIndex: zIndex, + cursor: isDragging ? 'grabbing' : 'default', + resize: 'both', + overflow: 'hidden' + }; + + // Sync size changes from CSS resize with state (debounced for performance) + React.useEffect(() => { + let resizeTimer: NodeJS.Timeout; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target === floatingRef.current && !isDragging) { + const newWidth = entry.contentRect.width; + const newHeight = entry.contentRect.height; + + if (newWidth !== size.width || newHeight !== size.height) { + // Update visual size immediately for smooth UX + setSize({ width: newWidth, height: newHeight }); + + // Debounce model update to avoid performance issues + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const rect = new Rect(position.x, position.y, newWidth, newHeight); + onUpdateFloating(floatingId, rect, zIndex); + }, 100); // 100ms debounce + } + } + } + }); + + if (floatingRef.current) { + resizeObserver.observe(floatingRef.current); + } + + return () => { + clearTimeout(resizeTimer); + resizeObserver.disconnect(); + }; + }, [position, size, zIndex, floatingId, onUpdateFloating, isDragging]); + + return ( +
{ + // Prevent dragging from floating tab content to main layout + e.preventDefault(); + e.stopPropagation(); + }} + draggable={false} + + + onMouseDown={handleMouseDown} + > +
+
+ {tabNode?.getName() || "[Unnamed Tab]"} +
+
+ + +
+
+
e.stopPropagation()} + onDragStart={(e) => { + // Prevent dragging from floating tab content to main layout + e.preventDefault(); + e.stopPropagation(); + }} + draggable={false} + > + {children} +
+
+ ); +}; \ No newline at end of file diff --git a/src/view/Icons.tsx b/src/view/Icons.tsx index 911065bc..b89b2995 100644 --- a/src/view/Icons.tsx +++ b/src/view/Icons.tsx @@ -75,6 +75,16 @@ export const MenuIcon = () => { } +export const FloatIcon = () => { + return ( + + + + + + ); +} + export const SettingsIcon = (props: React.SVGProps) => { return ( diff --git a/src/view/Layout.tsx b/src/view/Layout.tsx index 1d6a6c7a..092b43fe 100755 --- a/src/view/Layout.tsx +++ b/src/view/Layout.tsx @@ -20,7 +20,8 @@ import { BorderTab } from "./BorderTab"; import { BorderTabSet } from "./BorderTabSet"; import { DragContainer } from "./DragContainer"; import { PopoutWindow } from "./PopoutWindow"; -import { AsterickIcon, CloseIcon, EdgeIcon, MaximizeIcon, OverflowIcon, PopoutIcon, RestoreIcon } from "./Icons"; +import { FloatingTab } from "./FloatingTab"; +import { AsterickIcon, CloseIcon, EdgeIcon, FloatIcon, MaximizeIcon, OverflowIcon, PopoutIcon, RestoreIcon } from "./Icons"; import { Overlay } from "./Overlay"; import { Row } from "./Row"; import { Tab } from "./Tab"; @@ -364,12 +365,14 @@ export class LayoutInternal extends React.Component ); } @@ -558,6 +562,36 @@ export class LayoutInternal extends React.Component + {component} + + ); + } + } + + return floatingTabs; + } + renderTabMoveables() { const tabMoveables = new Map(); @@ -867,6 +901,52 @@ export class LayoutInternal extends React.Component { }; + onCloseFloating = (floatingId: string) => { + const floating = this.props.model.getFloatingsMap().get(floatingId); + if (floating) { + // Delete the tab node which will also clean up the floating reference + this.doAction(Actions.deleteTab(floating.tabId)); + // Also remove from floating map + this.props.model.removeFloating(floatingId); + } + }; + + onDockFloating = (floatingId: string, x: number, y: number) => { + // Use the action system - this will trigger model change listeners + this.doAction(Actions.unfloatTab(floatingId)); + }; + + onUpdateFloating = (floatingId: string, rect: Rect, zIndex: number) => { + const floating = this.props.model.getFloatingsMap().get(floatingId); + if (floating) { + floating.rect = rect.toJson(); + floating.zIndex = zIndex; + } + }; + + findDropTargetAtPosition = (x: number, y: number): { nodeId: string; location: DockLocation; index: number } | null => { + // Convert screen coordinates to layout coordinates + const layoutRect = this.getDomRect(); + const localX = x - layoutRect.x; + const localY = y - layoutRect.y; + + // Find the first tabset as a fallback + const firstTabSet = this.props.model.getFirstTabSet(); + if (firstTabSet) { + return { + nodeId: firstTabSet.getId(), + location: DockLocation.CENTER, + index: -1 + }; + } + + return null; + }; + + getDropTargetAtPoint = (x: number, y: number) => { + return this.findDropTargetAtPosition(x, y); + }; + getScreenRect(inRect: Rect) { const rect = inRect.clone(); const layoutRect = this.getDomRect(); @@ -1283,6 +1363,7 @@ export interface IIcons { close?: (React.ReactNode | ((tabNode: TabNode) => React.ReactNode)); closeTabset?: (React.ReactNode | ((tabSetNode: TabSetNode) => React.ReactNode)); popout?: (React.ReactNode | ((tabNode: TabNode) => React.ReactNode)); + float?: (React.ReactNode | ((tabNode: TabNode) => React.ReactNode)); maximize?: (React.ReactNode | ((tabSetNode: TabSetNode) => React.ReactNode)); restore?: (React.ReactNode | ((tabSetNode: TabSetNode) => React.ReactNode)); more?: (React.ReactNode | ((tabSetNode: (TabSetNode | BorderNode), hiddenTabs: { node: TabNode; index: number }[]) => React.ReactNode)); @@ -1294,6 +1375,7 @@ const defaultIcons = { close: , closeTabset: , popout: , + float: , maximize: , restore: , more: , diff --git a/src/view/TabSet.tsx b/src/view/TabSet.tsx index 998127cd..0fc1ff8b 100755 --- a/src/view/TabSet.tsx +++ b/src/view/TabSet.tsx @@ -144,6 +144,14 @@ export const TabSet = (props: ITabSetProps) => { event.stopPropagation(); }; + const onFloatTab = (event: React.MouseEvent) => { + if (selectedTabNode !== undefined) { + console.log('Float button clicked for tab:', selectedTabNode.getId()); + layout.doAction(Actions.floatTab(selectedTabNode.getId())); + } + event.stopPropagation(); + }; + const onDoubleClick = (event: React.MouseEvent) => { if (node.canMaximize()) { layout.maximize(node); @@ -261,6 +269,23 @@ export const TabSet = (props: ITabSetProps) => { ); } + // Add float button if enabled + if (selectedTabNode !== undefined && selectedTabNode.isEnableFloat()) { + const floatTitle = "Float Tab"; + buttons.push( + + ); + } + if (node.canMaximize()) { const minTitle = layout.i18nName(I18nLabel.Restore); const maxTitle = layout.i18nName(I18nLabel.Maximize); diff --git a/style/_base.scss b/style/_base.scss index a222a61c..4b5ea391 100644 --- a/style/_base.scss +++ b/style/_base.scss @@ -706,6 +706,61 @@ } } + &__floating_tab { + position: absolute; + background: var(--color-tab-content); + border: 1px solid var(--color-tabset-divider-line); + border-radius: 5px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + z-index: 1000; + overflow: hidden; + font-family: var(--font-family); + font-size: var(--font-size); + + &_header { + background: var(--color-tabset-background); + border-bottom: 1px solid var(--color-tabset-divider-line); + padding: 0px 2px 0px 2px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: grab; + user-select: none; + min-height: 32px; + box-sizing: border-box; + + &:active { + cursor: grabbing; + } + } + + &_title { + font-weight: normal; + color: var(--color-tab-selected); + padding: 3px 0.5em; + display: flex; + align-items: center; + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &_close, + &_dock { + // These buttons now use the tab_toolbar_button styles from above + } + + &_content { + flex: 1; + overflow: auto; + background: var(--color-tab-content); + box-sizing: border-box; + } + } + &__error_boundary_container { @include absoluteFill; display: flex;