From 5b41ab863ae910563d4b36404a44328a7893ad3d Mon Sep 17 00:00:00 2001 From: XFeng Date: Sat, 28 Feb 2026 15:24:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=B9=B6=E7=8B=AC=E7=AB=8B=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E7=9A=84=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/hooks/useTab.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/renderer/hooks/useTab.ts b/src/renderer/hooks/useTab.ts index 44f1387..c9bd253 100644 --- a/src/renderer/hooks/useTab.ts +++ b/src/renderer/hooks/useTab.ts @@ -523,16 +523,18 @@ function getTabDataForTearOff(tabId: string): TearOffTabData | null { const tab = tabs.value.find((t) => t.id === tabId); if (!tab) return null; + // toRaw 剥离 Vue reactive proxy,否则 IPC structured clone 无法序列化 + const raw = toRaw(tab); return { - id: tab.id, - name: tab.name, - filePath: tab.filePath, - content: tab.content, - originalContent: tab.originalContent, - isModified: tab.isModified, - scrollRatio: tab.scrollRatio ?? 0, - readOnly: tab.readOnly, - fileTraits: tab.fileTraits, + id: raw.id, + name: raw.name, + filePath: raw.filePath, + content: raw.content, + originalContent: raw.originalContent, + isModified: raw.isModified, + scrollRatio: raw.scrollRatio ?? 0, + readOnly: raw.readOnly, + fileTraits: raw.fileTraits ? toRaw(raw.fileTraits) : undefined, }; } From 8b03f1c8ad4772097aa8d448787e5aef258469c3 Mon Sep 17 00:00:00 2001 From: XFeng Date: Sat, 28 Feb 2026 17:46:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20Tab=20?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E5=88=86=E7=A6=BB=E5=92=8C=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipcBridge.ts | 10 ++- src/main/windowManager.ts | 41 ++++++++- src/renderer/components/workspace/TabBar.vue | 85 ++++++++++++------ src/renderer/hooks/useTab.ts | 90 +++++++++++++++++++- 4 files changed, 191 insertions(+), 35 deletions(-) diff --git a/src/main/ipcBridge.ts b/src/main/ipcBridge.ts index e609d89..7616c71 100644 --- a/src/main/ipcBridge.ts +++ b/src/main/ipcBridge.ts @@ -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) { diff --git a/src/main/windowManager.ts b/src/main/windowManager.ts index 5ad7bcf..faaf09d 100644 --- a/src/main/windowManager.ts +++ b/src/main/windowManager.ts @@ -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; + } if (prev && !prev.isDestroyed()) { prev.webContents.send("tab:merge-preview-cancel"); @@ -279,6 +285,7 @@ function clearMergePreview(sourceWinId: number): void { let windowDragInterval: ReturnType | null = null; let windowDragSourceId: number | null = null; let windowDragTabData: TearOffTabData | null = null; +let windowDragSourceWin: BrowserWindow | null = null; /** * 开始以 ~60fps 让窗口跟随光标 @@ -294,6 +301,7 @@ export function startWindowDrag( stopWindowDrag(); windowDragSourceId = win.id; windowDragTabData = tabData; + windowDragSourceWin = win; let prevCX = -1, prevCY = -1; windowDragInterval = setInterval(() => { @@ -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); } @@ -318,6 +342,15 @@ 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 { @@ -325,6 +358,7 @@ export function finalizeWindowDragMerge(): BrowserWindow | null { const target = finalizeMergePreview(windowDragSourceId); windowDragSourceId = null; windowDragTabData = null; + windowDragSourceWin = null; return target; } @@ -334,6 +368,7 @@ export function clearWindowDragPreview(): void { } windowDragSourceId = null; windowDragTabData = null; + windowDragSourceWin = null; } // ─── 多 Tab 拖拽跟随(创建新窗口并跟随光标直到松手)───── diff --git a/src/renderer/components/workspace/TabBar.vue b/src/renderer/components/workspace/TabBar.vue index 65f8cc8..4652800 100644 --- a/src/renderer/components/workspace/TabBar.vue +++ b/src/renderer/components/workspace/TabBar.vue @@ -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: { @@ -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; + } +} + +/** 判断屏幕坐标是否在 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 || @@ -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) { @@ -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, @@ -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 }); } @@ -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 = { @@ -201,7 +232,7 @@ function handleDragEnd(event: { oldIndex: number; newIndex: number }) { initialOffsetX: 0, initialOffsetY: 0, }; - cachedBounds = null; + cachedTabBarBounds = null; if (singleTabDragActive) { endSingleTabDrag(lastScreenX, lastScreenY); @@ -209,11 +240,9 @@ function handleDragEnd(event: { oldIndex: number; newIndex: number }) { } if (tearOffTriggered && tabId) { - // 必须通过数据驱动视图更新,确保 SortableJS 内部状态重置 - // 使用 requestAnimationFrame 确保 UI 更新后再执行结束逻辑 - requestAnimationFrame(() => { - endTearOff(tabId, lastScreenX, lastScreenY); - }); + // endTearOff 是 async,IPC 往返已提供充分延迟让 SortableJS 完成清理 + // 不再用 requestAnimationFrame 包装,避免 async 续体在 RAF 微任务中调度的新 RAF 被帧调度器跳过 + endTearOff(tabId, lastScreenX, lastScreenY); return; } diff --git a/src/renderer/hooks/useTab.ts b/src/renderer/hooks/useTab.ts index c9bd253..34986b8 100644 --- a/src/renderer/hooks/useTab.ts +++ b/src/renderer/hooks/useTab.ts @@ -538,9 +538,14 @@ function getTabDataForTearOff(tabId: string): TearOffTabData | null { }; } +/** 记录当前正在分离的 Tab ID,用于取消分离时恢复 */ +let tearOffSourceTabId: string | null = null; + /** * 开始拖拽分离:立即创建新窗口并跟随光标 * 由 TabBar 的 pointermove 在指针离开窗口时调用(fire-and-forget) + * + * 同时立即切换到相邻 Tab,避免源窗口继续显示被拖出的内容 */ function startTearOff( tabId: string, @@ -551,13 +556,29 @@ function startTearOff( ): void { const tabData = getTabDataForTearOff(tabId); if (!tabData) return; + + // 立即切换到相邻 Tab,使源窗口显示正确的内容 + tearOffSourceTabId = tabId; + const tabIndex = tabs.value.findIndex((t) => t.id === tabId); + if (tabIndex !== -1 && tabs.value.length > 1) { + // 优先切换到后一个 Tab,没有则切换到前一个 + const nextIndex = tabIndex < tabs.value.length - 1 ? tabIndex + 1 : tabIndex - 1; + switchToTab(tabs.value[nextIndex].id); + } + window.electronAPI.tearOffTabStart(tabData, screenX, screenY, offsetX, offsetY); } /** * 取消拖拽分离:指针回到源窗口时调用,关闭已创建的跟随窗口 + * 同时恢复到被拖出的 Tab */ function cancelTearOff(): void { + // 恢复到被拖出的 Tab + if (tearOffSourceTabId) { + switchToTab(tearOffSourceTabId); + tearOffSourceTabId = null; + } window.electronAPI.tearOffTabCancel(); } @@ -568,18 +589,41 @@ function cancelTearOff(): void { async function endTearOff(tabId: string, screenX: number, screenY: number): Promise { try { const result = await window.electronAPI.tearOffTabEnd(screenX, screenY); - if (result.action === "failed") return false; + if (result.action === "failed") { + // 分离失败,恢复到被拖出的 Tab + if (tearOffSourceTabId) { + switchToTab(tearOffSourceTabId); + } + tearOffSourceTabId = null; + return false; + } + + tearOffSourceTabId = null; // 成功创建新窗口或合并后,从当前窗口移除该 Tab + // 由于 startTearOff 时已切换到相邻 Tab,close 只需移除该 Tab 即可 const isLastTab = tabs.value.length === 1; if (isLastTab) { window.electronAPI.closeDiscard(); } else { close(tabId); + // 保底:编辑器的 setMarkdown 在 RAF 中执行,可能因帧调度时序被跳过 + // 用 setTimeout 确保内容刷新一定生效 + setTimeout(() => { + const current = getCurrentTab(); + if (current) { + emitter.emit("file:Change"); + } + }, 50); } return true; } catch (error) { + // 分离异常,恢复到被拖出的 Tab + if (tearOffSourceTabId) { + switchToTab(tearOffSourceTabId); + } + tearOffSourceTabId = null; console.error("[useTab] Tab 拖拽分离失败:", error); return false; } @@ -730,9 +774,53 @@ function handleTabMergePreviewFinalize() { } } +/** + * 动态更新合并预览 Tab 的插入位置 + * 由主进程在光标移动时持续发送,实现拖拽悬停时预览 Tab 跟随光标变换顺序 + */ +function handleTabMergePreviewUpdate(screenX: number, screenY: number) { + if (!mergePreviewState || mergePreviewState.isExisting) return; + + const { tabId } = mergePreviewState; + const currentIndex = tabs.value.findIndex((t) => t.id === tabId); + if (currentIndex === -1) return; + + // 将屏幕坐标转为页面内坐标 + const clientX = screenX - window.screenX; + + // 获取所有非预览 Tab 元素 + const tabElements = Array.from( + document.querySelectorAll("[data-tab-id]:not(.merge-preview)") + ) as HTMLElement[]; + + // 默认放在末尾 + let targetIndex = tabs.value.length - 1; + for (let i = 0; i < tabElements.length; i++) { + const rect = tabElements[i].getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + if (clientX < centerX) { + // 找到该元素在 tabs 数组中的真实索引 + const elTabId = tabElements[i].dataset.tabId; + const realIndex = tabs.value.findIndex((t) => t.id === elTabId); + if (realIndex !== -1) { + // 如果预览 Tab 在目标之前,移除后索引需 -1 + targetIndex = currentIndex < realIndex ? realIndex - 1 : realIndex; + } + break; + } + } + + // 仅在位置真正变化时才更新,避免不必要的 Vue 响应式开销 + if (targetIndex !== currentIndex) { + const [tab] = tabs.value.splice(currentIndex, 1); + tabs.value.splice(targetIndex, 0, tab); + } +} + window.electronAPI.on("tab:merge-preview", handleTabMergePreview); window.electronAPI.on("tab:merge-preview-cancel", handleTabMergePreviewCancel); window.electronAPI.on("tab:merge-preview-finalize", handleTabMergePreviewFinalize); +window.electronAPI.on("tab:merge-preview-update", handleTabMergePreviewUpdate); // ── 跨窗口文件去重 ──────────────────────────────────────