diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 4aabd64d..f8d8dd3f 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; @@ -34,6 +35,7 @@ import { updateBrowserTabTitle, updateBrowserTabFavicon, reorderBrowserTab, + moveBrowserTab, recordClosedCard, type BrowserTab, } from '@/shared/state/dashboardLayoutSlice'; @@ -505,17 +507,22 @@ const BrowserCard: React.FC = ({ const tabDragRef = useRef<{ tabId: string; startX: number; + startY: number; isDragging: boolean; + detached: boolean; } | null>(null); const swapCooldown = useRef(false); const [dragTabId, setDragTabId] = useState(null); const [dragTabOffset, setDragTabOffset] = useState(0); + // Ghost pill following the cursor while a tab is dragged OUT of the strip (Push 6: drop on another card = absorbed, drop on canvas = new browser card). + const [detachGhost, setDetachGhost] = useState<{ x: number; y: number } | null>(null); + const DETACH_PX = 48; const handleTabPointerDown = useCallback((e: React.PointerEvent) => { e.stopPropagation(); const tabId = (e.currentTarget as HTMLElement).getAttribute('data-tab-id'); if (!tabId) return; - tabDragRef.current = { tabId, startX: e.clientX, isDragging: false }; + tabDragRef.current = { tabId, startX: e.clientX, startY: e.clientY, isDragging: false, detached: false }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }, []); @@ -523,9 +530,27 @@ const BrowserCard: React.FC = ({ const drag = tabDragRef.current; if (!drag) return; const dx = e.clientX - drag.startX; - if (!drag.isDragging && Math.abs(dx) < 5) return; + const dy = e.clientY - drag.startY; + if (!drag.isDragging && Math.abs(dx) < 5 && Math.abs(dy) < 5) return; drag.isDragging = true; setDragTabId(drag.tabId); + + // Pulling clear of the strip detaches the tab; hovering back over the strip re-attaches (Chrome behavior). + const barRect = tabBarRef.current?.getBoundingClientRect(); + if (barRect) { + const outside = e.clientY < barRect.top - DETACH_PX || e.clientY > barRect.bottom + DETACH_PX + || e.clientX < barRect.left - DETACH_PX || e.clientX > barRect.right + DETACH_PX; + const backInside = e.clientY >= barRect.top && e.clientY <= barRect.bottom + && e.clientX >= barRect.left && e.clientX <= barRect.right; + if (!drag.detached && outside) drag.detached = true; + else if (drag.detached && backInside) drag.detached = false; + } + if (drag.detached) { + setDetachGhost({ x: e.clientX, y: e.clientY }); + setDragTabOffset(0); + return; + } + setDetachGhost(null); setDragTabOffset(dx); if (swapCooldown.current) return; @@ -574,12 +599,30 @@ const BrowserCard: React.FC = ({ if (!drag) return; if (!drag.isDragging) { handleSwitchTab(drag.tabId); + } else if (drag.detached) { + // Hit-test the drop point: another browser card absorbs the tab; empty canvas spins off a new card there. + const hit = document.elementsFromPoint(e.clientX, e.clientY) + .map((el) => (el as HTMLElement).closest?.('[data-select-type="browser-card"]') as HTMLElement | null) + .find((el) => el && el.getAttribute('data-select-id') !== browserId); + const targetId = hit?.getAttribute('data-select-id') || null; + if (targetId) { + dispatch(moveBrowserTab({ fromBrowserId: browserId, tabId: drag.tabId, toBrowserId: targetId })); + } else { + // Screen -> canvas: derive the transform origin from this card's own strip (screenX = originX + canvasX * zoom). + const barRect = tabBarRef.current?.getBoundingClientRect(); + if (barRect) { + const dropX = (e.clientX - (barRect.left - cardX * zoomRef.current)) / zoomRef.current - 40; + const dropY = (e.clientY - (barRect.top - cardY * zoomRef.current)) / zoomRef.current - 16; + dispatch(moveBrowserTab({ fromBrowserId: browserId, tabId: drag.tabId, x: dropX, y: dropY })); + } + } } tabDragRef.current = null; setDragTabId(null); setDragTabOffset(0); + setDetachGhost(null); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); - }, [handleSwitchTab]); + }, [handleSwitchTab, dispatch, browserId, cardX, cardY]); const DRAG_THRESHOLD = 3; const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); @@ -1537,6 +1580,44 @@ const BrowserCard: React.FC = ({ /> ))} + {/* Detached-tab ghost: fixed-position pill under the cursor while a tab is dragged out of the strip. pointerEvents none so the drop hit-test sees the cards underneath it. */} + {detachGhost && dragTabId && createPortal( + (() => { + const ghostTab = tabs.find((t) => t.id === dragTabId); + return ( + + {ghostTab?.favicon ? ( + + ) : ( + + )} + + {ghostTab?.title || ghostTab?.url || 'Tab'} + + + ); + })(), + document.body, + )} + ); }; diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 44aaded6..8b170da1 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -1219,6 +1219,51 @@ const dashboardLayoutSlice = createSlice({ card.tabs.splice(Math.max(0, Math.min(action.payload.toIndex, card.tabs.length)), 0, tab); }, + // Drag a tab OUT of a browser card: into another card (absorbed, appended + activated) or onto empty canvas (spins off a new card at the drop point). Moving the last tab dissolves the source card, Chrome-style. + moveBrowserTab( + state, + action: PayloadAction<{ fromBrowserId: string; tabId: string; toBrowserId?: string; x?: number; y?: number }> + ) { + const { fromBrowserId, tabId, toBrowserId, x, y } = action.payload; + if (toBrowserId === fromBrowserId) return; + const source = state.browserCards[fromBrowserId]; + if (!source) return; + const idx = source.tabs.findIndex((t) => t.id === tabId); + if (idx === -1) return; + const target = toBrowserId ? state.browserCards[toBrowserId] : undefined; + if (toBrowserId && !target) return; + const [moved] = source.tabs.splice(idx, 1); + // Fresh id: reusing the old one makes the receiving BrowserCard think the tab is already initialized, so its webview never loads the URL and sits at about:blank. + const tab = { ...moved, id: generateTabId() }; + if (source.tabs.length === 0) { + delete state.browserCards[fromBrowserId]; + } else if (source.activeTabId === tabId) { + const nextActive = source.tabs[Math.min(idx, source.tabs.length - 1)]; + source.activeTabId = nextActive.id; + source.url = nextActive.url; + } + if (target) { + target.tabs.push(tab); + target.activeTabId = tab.id; + target.url = tab.url; + target.zOrder = state.nextZOrder++; + } else { + const id = `browser-${Date.now().toString(36)}`; + state.browserCards[id] = { + browser_id: id, + url: tab.url, + tabs: [tab], + activeTabId: tab.id, + x: x ?? source.x + 60, + y: y ?? source.y + 60, + width: source.width, + height: source.height, + zOrder: state.nextZOrder++, + dashboard_id: source.dashboard_id, + }; + } + }, + moveCards( state, action: PayloadAction<{ @@ -1610,6 +1655,7 @@ export const { updateBrowserTabTitle, updateBrowserTabFavicon, reorderBrowserTab, + moveBrowserTab, moveCards, setGlowingBrowserCards, fadeGlowingBrowserCards,