mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user