Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/main/ipcBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,9 +513,13 @@ export function registerGlobalIpcHandlers() {
try {
const sourceWin = BrowserWindow.fromWebContents(event.sender);
const result = await finalizeDragFollow(screenX, screenY, sourceWin);
// tear-off 完成后重新聚焦源窗口,避免需要额外点击才能交互
if (result.action === "created" && sourceWin && !sourceWin.isDestroyed()) {
sourceWin.focus();
// 延迟聚焦新窗口:让源窗口 renderer 先完成 close → switchToTab → 编辑器内容刷新
// 编辑器 setMarkdown 在 requestAnimationFrame 中执行,源窗口失焦后 RAF 会被限流
if (result.action === "created" && result.newWin && !result.newWin.isDestroyed()) {
const newWin = result.newWin;
setTimeout(() => {
if (!newWin.isDestroyed()) newWin.focus();
}, 200);
}
return { action: result.action };
} catch (error) {
Expand Down
41 changes: 38 additions & 3 deletions src/main/windowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,13 @@ function updateMergePreview(
}

const prev = mergePreviewTargets.get(sourceWinId) ?? null;
if (prev?.id === target?.id) return target;
if (prev?.id === target?.id) {
// 目标窗口未变,但光标位置变了 → 发送位置更新以动态调整预览 Tab 插入位置
if (target && !target.isDestroyed()) {
target.webContents.send("tab:merge-preview-update", screenX, screenY);
}
return target;
Comment on lines +247 to +252
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateMergePreview now sends tab:merge-preview-update every time the cursor moves while staying over the same target window. Given the 16ms drag intervals, this can create very high-frequency IPC traffic and renderer-side layout work. Consider throttling these updates (e.g., only send at most once per animation frame or every N ms) and/or only sending when the X position actually changes enough to affect the insertion index.

Copilot uses AI. Check for mistakes.
}

if (prev && !prev.isDestroyed()) {
prev.webContents.send("tab:merge-preview-cancel");
Expand Down Expand Up @@ -279,6 +285,7 @@ function clearMergePreview(sourceWinId: number): void {
let windowDragInterval: ReturnType<typeof setInterval> | null = null;
let windowDragSourceId: number | null = null;
let windowDragTabData: TearOffTabData | null = null;
let windowDragSourceWin: BrowserWindow | null = null;

/**
* 开始以 ~60fps 让窗口跟随光标
Expand All @@ -294,6 +301,7 @@ export function startWindowDrag(
stopWindowDrag();
windowDragSourceId = win.id;
windowDragTabData = tabData;
windowDragSourceWin = win;
let prevCX = -1,
prevCY = -1;
windowDragInterval = setInterval(() => {
Expand All @@ -305,10 +313,26 @@ export function startWindowDrag(
if (cursor.x === prevCX && cursor.y === prevCY) return;
prevCX = cursor.x;
prevCY = cursor.y;
win.setPosition(Math.round(cursor.x - offsetX), Math.round(cursor.y - offsetY));

let target: BrowserWindow | null = null;
if (windowDragSourceId && windowDragTabData) {
updateMergePreview(windowDragSourceId, windowDragTabData, cursor.x, cursor.y, [win]);
target = updateMergePreview(windowDragSourceId, windowDragTabData, cursor.x, cursor.y, [win]);
}

// 始终跟随光标移动(即使透明化也要移动,确保渲染进程能接收鼠标事件)
win.setPosition(Math.round(cursor.x - offsetX), Math.round(cursor.y - offsetY));

if (target) {
// 合并预览激活 → 透明化源窗口,让用户视觉上感知 Tab 已并入目标窗口
// 使用 setOpacity(0) 而非 hide(),保持窗口可接收鼠标事件(SortableJS 需要 pointerup)
if (win.getOpacity() > 0) {
win.setOpacity(0);
}
} else {
// 无目标窗口 → 恢复可见
if (win.getOpacity() === 0) {
win.setOpacity(1);
}
}
}, 16);
}
Expand All @@ -318,13 +342,23 @@ export function stopWindowDrag(): void {
clearInterval(windowDragInterval);
windowDragInterval = null;
}
// 恢复源窗口透明度(拖拽期间可能被透明化)
if (
windowDragSourceWin &&
!windowDragSourceWin.isDestroyed() &&
windowDragSourceWin.getOpacity() === 0
) {
windowDragSourceWin.setOpacity(1);
}
windowDragSourceWin = null;
}

export function finalizeWindowDragMerge(): BrowserWindow | null {
if (!windowDragSourceId) return null;
const target = finalizeMergePreview(windowDragSourceId);
windowDragSourceId = null;
windowDragTabData = null;
windowDragSourceWin = null;
return target;
}

Expand All @@ -334,6 +368,7 @@ export function clearWindowDragPreview(): void {
}
windowDragSourceId = null;
windowDragTabData = null;
windowDragSourceWin = null;
}

// ─── 多 Tab 拖拽跟随(创建新窗口并跟随光标直到松手)─────
Expand Down
85 changes: 57 additions & 28 deletions src/renderer/components/workspace/TabBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ async function handleCloseTab(id: string, event: Event) {

// ── Tab 拖拽分离检测 ────────────────────────────────────────

/** 鼠标超出窗口边界的阈值(px),避免微小越界误触发 */
const TEAR_OFF_THRESHOLD = 30;
/** 鼠标超出 Tab 栏边界的阈值(px),避免微小越界误触发 */
const TEAR_OFF_THRESHOLD = 20;

/** 拖拽期间的状态 */
let dragState: {
Expand All @@ -79,13 +79,34 @@ let dragState: {
initialOffsetY: 0,
};

/** 缓存的窗口边界 */
let cachedBounds: { x: number; y: number; width: number; height: number } | null = null;
/** 缓存的 Tab 栏屏幕边界(用于多 Tab 分离检测) */
let cachedTabBarBounds: { x: number; y: number; width: number; height: number } | null = null;

/** 判断屏幕坐标是否在窗口外 */
function isOutsideWindow(screenX: number, screenY: number): boolean {
if (!cachedBounds) return false;
const { x, y, width, height } = cachedBounds;
/** 保存被隐藏的 ghost 元素引用,tear-off 结束后清理 */
let _ghostEl: HTMLElement | null = null;

/** 隐藏 SortableJS ghost 元素(Tab 脱离 Tab 栏时调用,类似 Chrome 标签拽出后消失) */
function hideGhost() {
if (_ghostEl) return;
const el = tabContainerRef.value?.querySelector(".ghost") as HTMLElement | null;
if (el) {
_ghostEl = el;
el.style.display = "none";
}
}

/** 恢复 SortableJS ghost 元素(取消 tear-off 时调用) */
function showGhost() {
if (_ghostEl) {
_ghostEl.style.display = "";
_ghostEl = null;
}
}

Comment on lines +98 to +105
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hideGhost() sets an inline display: none on the element with .ghost. When the drag ends, SortableJS removes the ghostClass but won't restore inline styles, so the tab can remain permanently hidden if the tear-off ultimately fails (since handleDragEnd clears _ghostEl without calling showGhost()). Prefer hiding via a CSS rule keyed off body.tab-torn-off (so visibility auto-restores when the class is removed), or ensure the inline style is always reverted on drag end / tear-off failure.

Suggested change
/** 恢复 SortableJS ghost 元素(取消 tear-off 时调用) */
function showGhost() {
if (_ghostEl) {
_ghostEl.style.display = "";
_ghostEl = null;
}
}
/** 恢复 SortableJS ghost 元素(取消 tear-off 或拖拽结束时调用) */
function showGhost() {
// 尝试恢复当前 DOM 中的 ghost 元素的 display,即使 _ghostEl 已被其他逻辑清空
const container = tabContainerRef.value;
if (container) {
const ghostEl = container.querySelector(".ghost") as HTMLElement | null;
if (ghostEl && ghostEl.style.display === "none") {
ghostEl.style.display = "";
}
}
// 同时处理通过 hideGhost() 缓存过的元素,确保其样式被恢复
if (_ghostEl) {
_ghostEl.style.display = "";
_ghostEl = null;
}
}
// 全局监听拖拽结束事件,确保无论 tear-off 是否成功,ghost 样式都会被恢复
const handleGlobalDragEnd = () => {
showGhost();
};
onMounted(() => {
window.addEventListener("dragend", handleGlobalDragEnd);
});
onUnmounted(() => {
window.removeEventListener("dragend", handleGlobalDragEnd);
});

Copilot uses AI. Check for mistakes.
/** 判断屏幕坐标是否在 Tab 栏外(类似 Chrome,拖出 Tab 栏即分离) */
function isOutsideTabBar(screenX: number, screenY: number): boolean {
if (!cachedTabBarBounds) return false;
const { x, y, width, height } = cachedTabBarBounds;
return (
screenX < x - TEAR_OFF_THRESHOLD ||
screenX > x + width + TEAR_OFF_THRESHOLD ||
Expand All @@ -97,7 +118,7 @@ function isOutsideWindow(screenX: number, screenY: number): boolean {
/**
* 拖拽期间的 pointer 位置追踪
*
* 多 Tab:指针离开窗口 → 立即创建新窗口跟随光标(fire-and-forget)
* 多 Tab:指针离开 Tab 栏 → 立即创建新窗口跟随光标(fire-and-forget)
* 单 Tab:直接进入窗口拖拽模式(由主进程 setInterval 驱动位置更新)
*/
function onDragPointerMove(e: PointerEvent) {
Expand All @@ -111,31 +132,32 @@ function onDragPointerMove(e: PointerEvent) {

if (isSingleTab.value) {
// ── 单 Tab:立即开始窗口拖拽 ──
if (!cachedBounds) return;
dragState.singleTabDragActive = true;
const offsetX = e.screenX - cachedBounds.x;
const offsetY = e.screenY - cachedBounds.y;
const offsetX = e.screenX - window.screenX;
const offsetY = e.screenY - window.screenY;
startSingleTabDrag(dragState.tabId, offsetX, offsetY);
document.body.classList.add("tab-torn-off");
return;
}

// 多 Tab 模式:检测拖拽分离与回拖
// 多 Tab 模式:检测拖拽分离与回拖(基于 Tab 栏边界)
if (dragState.tearOffTriggered) {
// 已触发 tear-off,检查指针是否回到窗口内
if (!isOutsideWindow(e.screenX, e.screenY)) {
// 指针回到窗口内 → 取消分离,关闭跟随窗口
// 已触发 tear-off,检查指针是否回到 Tab 栏内
if (!isOutsideTabBar(e.screenX, e.screenY)) {
// 指针回到 Tab 栏内 → 取消分离,关闭跟随窗口
dragState.tearOffTriggered = false;
document.body.classList.remove("tab-torn-off");
showGhost();
cancelTearOff();
}
return;
}

if (isOutsideWindow(e.screenX, e.screenY)) {
// ── 多 Tab:指针离开窗口 → 开始分离跟随 ──
if (isOutsideTabBar(e.screenX, e.screenY)) {
// ── 多 Tab:指针离开 Tab 栏 → 开始分离跟随 ──
dragState.tearOffTriggered = true;
document.body.classList.add("tab-torn-off");
hideGhost();
startTearOff(
dragState.tabId,
e.screenX,
Expand Down Expand Up @@ -173,10 +195,17 @@ function handleDragStart(event: any) {
initialOffsetY,
};

// 非阻塞获取窗口边界,tear-off 检测在边界就绪后自动激活
window.electronAPI.getWindowBounds().then((bounds) => {
cachedBounds = bounds;
});
// 同步计算 Tab 栏屏幕边界(用于多 Tab 分离检测,类似 Chrome 拖出 Tab 栏即分离)
const container = tabContainerRef.value;
if (container) {
const rect = container.getBoundingClientRect();
cachedTabBarBounds = {
x: rect.left + window.screenX,
y: rect.top + window.screenY,
width: rect.width,
height: rect.height,
};
}

document.addEventListener("pointermove", onDragPointerMove, { capture: true });
}
Expand All @@ -190,6 +219,8 @@ function handleDragStart(event: any) {
function handleDragEnd(event: { oldIndex: number; newIndex: number }) {
document.removeEventListener("pointermove", onDragPointerMove, { capture: true });
document.body.classList.remove("tab-torn-off");
// tear-off 情况下不恢复 ghost(tab 即将被移除),仅清理引用
_ghostEl = null;

const { tabId, tearOffTriggered, singleTabDragActive, lastScreenX, lastScreenY } = dragState;
dragState = {
Expand All @@ -201,19 +232,17 @@ function handleDragEnd(event: { oldIndex: number; newIndex: number }) {
initialOffsetX: 0,
initialOffsetY: 0,
};
cachedBounds = null;
cachedTabBarBounds = null;

if (singleTabDragActive) {
endSingleTabDrag(lastScreenX, lastScreenY);
return;
}

if (tearOffTriggered && tabId) {
// 必须通过数据驱动视图更新,确保 SortableJS 内部状态重置
// 使用 requestAnimationFrame 确保 UI 更新后再执行结束逻辑
requestAnimationFrame(() => {
endTearOff(tabId, lastScreenX, lastScreenY);
});
// endTearOff 是 async,IPC 往返已提供充分延迟让 SortableJS 完成清理
// 不再用 requestAnimationFrame 包装,避免 async 续体在 RAF 微任务中调度的新 RAF 被帧调度器跳过
endTearOff(tabId, lastScreenX, lastScreenY);
return;
}

Expand Down
Loading
Loading