[eric] browser: drag a tab out of the strip (drop on a card absorbs it, drop on canvas opens a new browser)

This commit is contained in:
ciregenz
2026-07-02 01:29:32 -07:00
parent 85d77ca552
commit df603a0b75
2 changed files with 130 additions and 3 deletions
@@ -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<Props> = ({
const tabDragRef = useRef<{
tabId: string;
startX: number;
startY: number;
isDragging: boolean;
detached: boolean;
} | null>(null);
const swapCooldown = useRef(false);
const [dragTabId, setDragTabId] = useState<string | null>(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<Props> = ({
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<Props> = ({
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<Props> = ({
/>
))}
{/* 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 (
<Box
sx={{
position: 'fixed',
left: detachGhost.x + 10,
top: detachGhost.y + 10,
zIndex: 2147483647,
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.5,
maxWidth: 240,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.md}px`,
boxShadow: c.shadow.lg,
}}
>
{ghostTab?.favicon ? (
<Box component="img" src={ghostTab.favicon} sx={{ width: 14, height: 14, flexShrink: 0 }} />
) : (
<LanguageIcon sx={{ fontSize: 14, color: c.text.muted, flexShrink: 0 }} />
)}
<Typography sx={{ fontSize: '0.75rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ghostTab?.title || ghostTab?.url || 'Tab'}
</Typography>
</Box>
);
})(),
document.body,
)}
</Box>
);
};
@@ -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,