diff --git a/lang/index.js b/lang/index.js
index f9b41fa..f09b7d7 100644
--- a/lang/index.js
+++ b/lang/index.js
@@ -1,4 +1,4 @@
-// 导入国际化JSON文件
+// 导入国际化JSON文件(合并模式)
import langJSON from "./index.json";
(function () {
// 定义翻译函数
@@ -42,7 +42,7 @@ import langJSON from "./index.json";
globalThis.$t = globalThis.$t || $t;
// 将简单翻译函数挂载到globalThis对象上
globalThis.$$t = $$t;
- // 定义从JSON文件中获取指定键的语言对象的方法
+ // 定义从JSON文件中获取指定键的语言对象的方法(合并模式)
globalThis._getJSONKey = function (key, insertJSONObj = undefined) {
// 获取JSON对象
const JSONObj = insertJSONObj;
diff --git a/lang/index.json b/lang/index.json
index d07c897..dea8262 100644
--- a/lang/index.json
+++ b/lang/index.json
@@ -3446,5 +3446,13 @@
"ru": "",
"en": "",
"fr": ""
+ },
+ "gl01kl7e": {
+ "zh-cn": "\n
\n\n# MilkUp\n\n
\n\n> 这是一段测试文字\n\n
\n\n***\n\n
\n\n* item1\n* item2\n* item3\n\n代码:\n\n```JavaScript\nconst text = \"Hello Word!\"\nconsole.log(text)\n```\n\n
\n\n| 1 | 2 | 3 |\n| :- | :- | :- |\n| 1 | 2 | 3 |\n| 1 | 2 | 3 |\n\n\n",
+ "ja": "",
+ "ko": "",
+ "ru": "",
+ "en": "",
+ "fr": ""
}
}
diff --git a/src/main/index.ts b/src/main/index.ts
index ac4fabd..a28118e 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -11,6 +11,7 @@ import {
} from "./ipcBridge";
import createMenu from "./menu";
import { setupUpdateHandlers } from "./update";
+import { trackWindow } from "./windowManager";
let win: BrowserWindow;
let themeEditorWindow: BrowserWindow | null = null;
@@ -34,6 +35,10 @@ async function createWindow() {
webSecurity: false, // 允许加载本地文件
},
});
+
+ // 注册为主窗口
+ trackWindow(win, true);
+
globalShortcut.register("CommandOrControl+Shift+I", () => {
if (win) win.webContents.openDevTools();
});
diff --git a/src/main/ipcBridge.ts b/src/main/ipcBridge.ts
index 8ec4d7b..68da06d 100644
--- a/src/main/ipcBridge.ts
+++ b/src/main/ipcBridge.ts
@@ -17,37 +17,62 @@ import {
restoreFileTraits,
} from "./fileFormat";
import { createThemeEditorWindow } from "./index";
-
-let isSaved = true;
-let isQuitting = false;
+import {
+ cancelDragFollow,
+ clearWindowDragPreview,
+ consumePendingTabData,
+ finalizeWindowDragMerge,
+ finalizeDragFollow,
+ findWindowWithFile,
+ getEditorWindows,
+ isMainWindow,
+ startDragFollow,
+ startWindowDrag,
+ stopWindowDrag,
+ updateWindowOpenFiles,
+} from "./windowManager";
+import type { TearOffTabData } from "./windowManager";
+
+/** 每个窗口独立追踪保存状态(windowId → isSaved) */
+const windowSaveState = new Map();
+/** 正在执行关闭流程的窗口集合 */
+const windowClosingSet = new Set();
+
+/** 窗口关闭后清理状态,防止内存泄漏 */
+function cleanupWindowState(windowId: number): void {
+ windowSaveState.delete(windowId);
+ windowClosingSet.delete(windowId);
+}
// 存储已监听的文件路径和对应的 watcher
const watchedFiles = new Set();
let watcher: FSWatcher | null = null;
-// 所有 on 类型监听
+// 所有 on 类型监听 —— 使用 event.sender 自动路由到正确窗口
export function registerIpcOnHandlers(win: Electron.BrowserWindow) {
- ipcMain.on("set-title", (_event, filePath: string | null) => {
+ ipcMain.on("set-title", (event, filePath: string | null) => {
+ const targetWin = BrowserWindow.fromWebContents(event.sender);
+ if (!targetWin) return;
const title = filePath ? `milkup - ${path.basename(filePath)}` : "milkup - Untitled";
- win.setTitle(title);
+ targetWin.setTitle(title);
});
- ipcMain.on("window-control", async (_event, action) => {
- if (!win) return;
+ ipcMain.on("window-control", async (event, action) => {
+ const targetWin = BrowserWindow.fromWebContents(event.sender);
+ if (!targetWin) return;
switch (action) {
case "minimize":
- win.minimize();
+ targetWin.minimize();
break;
case "maximize":
- if (win.isMaximized()) win.unmaximize();
- else win.maximize();
+ if (targetWin.isMaximized()) targetWin.unmaximize();
+ else targetWin.maximize();
break;
case "close":
- if (process.platform === "darwin") {
- // 在 macOS 上,窗口关闭按钮只隐藏窗口
- win.hide();
+ if (process.platform === "darwin" && isMainWindow(targetWin)) {
+ // macOS 主窗口按关闭按钮仅隐藏
+ targetWin.hide();
} else {
- // 其他平台直接退出
- close(win);
+ close(targetWin);
}
break;
}
@@ -55,21 +80,31 @@ export function registerIpcOnHandlers(win: Electron.BrowserWindow) {
ipcMain.on("shell:openExternal", (_event, url) => {
shell.openExternal(url);
});
- ipcMain.on("change-save-status", (_event, isSavedStatus) => {
- isSaved = isSavedStatus;
- win.webContents.send("save-status-changed", isSaved);
+ ipcMain.on("change-save-status", (event, isSavedStatus) => {
+ const targetWin = BrowserWindow.fromWebContents(event.sender);
+ if (!targetWin) return;
+ windowSaveState.set(targetWin.id, isSavedStatus);
+ event.sender.send("save-status-changed", isSavedStatus);
});
// 监听保存事件
- ipcMain.on("menu-save", async (_event, shouldClose) => {
- win.webContents.send("trigger-save", shouldClose);
+ ipcMain.on("menu-save", async (event, shouldClose) => {
+ event.sender.send("trigger-save", shouldClose);
});
// 监听丢弃更改事件
- ipcMain.on("close:discard", () => {
- isQuitting = true;
- win.close();
- app.quit();
+ ipcMain.on("close:discard", (event) => {
+ const targetWin = BrowserWindow.fromWebContents(event.sender);
+ if (!targetWin || targetWin.isDestroyed()) return;
+ const winId = targetWin.id;
+ windowClosingSet.add(winId);
+ targetWin.close();
+ cleanupWindowState(winId);
+ // 如果是最后一个窗口(非 macOS),退出应用
+ const remaining = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
+ if (remaining.length === 0 && process.platform !== "darwin") {
+ app.quit();
+ }
});
// 打开主题编辑器窗口
@@ -124,7 +159,7 @@ export function registerIpcOnHandlers(win: Electron.BrowserWindow) {
});
}
-// 所有 handle 类型监听
+// 所有 handle 类型监听 —— 使用 event.sender 路由到正确窗口
export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
// 检查文件是否只读
ipcMain.handle("file:isReadOnly", async (_event, filePath: string) => {
@@ -132,8 +167,9 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
});
// 文件打开对话框
- ipcMain.handle("dialog:openFile", async () => {
- const { canceled, filePaths } = await dialog.showOpenDialog(win, {
+ ipcMain.handle("dialog:openFile", async (event) => {
+ const parentWin = BrowserWindow.fromWebContents(event.sender) ?? win;
+ const { canceled, filePaths } = await dialog.showOpenDialog(parentWin, {
filters: [{ name: "Markdown", extensions: ["md", "markdown"] }],
properties: ["openFile"],
});
@@ -149,15 +185,16 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
ipcMain.handle(
"dialog:saveFile",
async (
- _event,
+ event,
{
filePath,
content,
fileTraits,
}: { filePath: string | null; content: string; fileTraits?: FileTraits }
) => {
+ const parentWin = BrowserWindow.fromWebContents(event.sender) ?? win;
if (!filePath) {
- const { canceled, filePath: savePath } = await dialog.showSaveDialog(win, {
+ const { canceled, filePath: savePath } = await dialog.showSaveDialog(parentWin, {
filters: [{ name: "Markdown", extensions: ["md", "markdown"] }],
});
if (canceled || !savePath) return null;
@@ -170,8 +207,9 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
}
);
// 文件另存为对话框
- ipcMain.handle("dialog:saveFileAs", async (_event, content) => {
- const { canceled, filePath } = await dialog.showSaveDialog(win, {
+ ipcMain.handle("dialog:saveFileAs", async (event, content) => {
+ const parentWin = BrowserWindow.fromWebContents(event.sender) ?? win;
+ const { canceled, filePath } = await dialog.showSaveDialog(parentWin, {
filters: [{ name: "Markdown", extensions: ["md", "markdown"] }],
});
if (canceled || !filePath) return null;
@@ -180,14 +218,16 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
});
// 同步显示消息框
- ipcMain.handle("dialog:OpenDialog", async (_event, options: Electron.MessageBoxSyncOptions) => {
- const response = await dialog.showMessageBox(win, options);
+ ipcMain.handle("dialog:OpenDialog", async (event, options: Electron.MessageBoxSyncOptions) => {
+ const parentWin = BrowserWindow.fromWebContents(event.sender) ?? win;
+ const response = await dialog.showMessageBox(parentWin, options);
return response;
});
// 显示文件覆盖确认对话框
- ipcMain.handle("dialog:showOverwriteConfirm", async (_event, fileName: string) => {
- const result = await dialog.showMessageBox(win, {
+ ipcMain.handle("dialog:showOverwriteConfirm", async (event, fileName: string) => {
+ const parentWin = BrowserWindow.fromWebContents(event.sender) ?? win;
+ const result = await dialog.showMessageBox(parentWin, {
type: "question",
buttons: ["取消", "覆盖", "保存"],
defaultId: 0,
@@ -199,8 +239,9 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
});
// 显示关闭确认对话框
- ipcMain.handle("dialog:showCloseConfirm", async (_event, fileName: string) => {
- const result = await dialog.showMessageBox(win, {
+ ipcMain.handle("dialog:showCloseConfirm", async (event, fileName: string) => {
+ const parentWin = BrowserWindow.fromWebContents(event.sender) ?? win;
+ const result = await dialog.showMessageBox(parentWin, {
type: "question",
buttons: ["取消", "不保存", "保存"],
defaultId: 2,
@@ -212,19 +253,22 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
});
// 显示文件选择对话框
- ipcMain.handle("dialog:showOpenDialog", async (_event, options: any) => {
- const result = await dialog.showOpenDialog(win, options);
+ ipcMain.handle("dialog:showOpenDialog", async (event, options: any) => {
+ const parentWin = BrowserWindow.fromWebContents(event.sender) ?? win;
+ const result = await dialog.showOpenDialog(parentWin, options);
return result;
});
// 导出为 pdf 文件
ipcMain.handle(
"file:exportPDF",
async (
- _event,
+ event,
elementSelector: string,
outputName: string,
options?: ExportPDFOptions
): Promise => {
+ const sender = event.sender;
+ const parentWin = BrowserWindow.fromWebContents(sender) ?? win;
const { pageSize = "A4", scale = 1 } = options || {};
// 保证代码块完整显示
@@ -252,10 +296,10 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
}
`;
- const cssKey = await win.webContents.insertCSS(preventCutOffStyle);
+ const cssKey = await sender.insertCSS(preventCutOffStyle);
// 1. 在页面中克隆元素并隐藏其他内容
- await win.webContents.executeJavaScript(`
+ await sender.executeJavaScript(`
(function() {
const target = document.querySelector('${elementSelector}');
if (!target) throw new Error('Element not found');
@@ -282,7 +326,7 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
`);
try {
// 2. 导出 PDF
- const pdfData = await win.webContents.printToPDF({
+ const pdfData = await sender.printToPDF({
printBackground: true,
pageSize,
margins: {
@@ -292,7 +336,7 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
});
// 3. 保存 PDF 文件
- const { canceled, filePath } = await dialog.showSaveDialog(win, {
+ const { canceled, filePath } = await dialog.showSaveDialog(parentWin, {
title: "导出为 PDF",
defaultPath: outputName || "export.pdf",
filters: [{ name: "PDF", extensions: ["pdf"] }],
@@ -306,7 +350,7 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
return Promise.reject(error);
} finally {
// 4. 清理页面
- win.webContents.executeJavaScript(`
+ sender.executeJavaScript(`
(function() {
const container = document.querySelector('.electron-export-container');
if (container) container.remove();
@@ -314,14 +358,14 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
})();
`);
// 移除插入的样式
- if (cssKey) win.webContents.removeInsertedCSS(cssKey);
+ if (cssKey) sender.removeInsertedCSS(cssKey);
}
}
);
// 导出为 word 文件
ipcMain.handle(
"file:exportWord",
- async (_event, blocks: Block[], outputName: string): Promise => {
+ async (event, blocks: Block[], outputName: string): Promise => {
// 定义 Word 的列表样式
const sectionChildren: Paragraph[] = [];
@@ -412,8 +456,9 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
});
const buffer = await Packer.toBuffer(doc);
+ const parentWin = BrowserWindow.fromWebContents(event.sender) ?? win;
- const { canceled, filePath } = await dialog.showSaveDialog(win, {
+ const { canceled, filePath } = await dialog.showSaveDialog(parentWin, {
title: "导出为 Word",
defaultPath: outputName || "export.docx",
filters: [{ name: "Word Document", extensions: ["docx"] }],
@@ -431,6 +476,139 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) {
}
// 无需 win 的 ipc 处理
export function registerGlobalIpcHandlers() {
+ // ── Tab 拖拽分离:开始跟随(创建新窗口并跟随光标)────
+ ipcMain.handle(
+ "tab:tear-off-start",
+ async (
+ event,
+ tabData: TearOffTabData,
+ screenX: number,
+ screenY: number,
+ offsetX: number,
+ offsetY: number
+ ): Promise => {
+ try {
+ const sourceWin = BrowserWindow.fromWebContents(event.sender);
+ startDragFollow(tabData, screenX, screenY, offsetX, offsetY, sourceWin);
+ return true;
+ } catch (error) {
+ console.error("[tab:tear-off-start] 创建窗口失败:", error);
+ return false;
+ }
+ }
+ );
+
+ // ── Tab 拖拽分离:完成跟随(松手时判断合并或保留)──
+ ipcMain.handle(
+ "tab:tear-off-end",
+ async (
+ event,
+ screenX: number,
+ screenY: number
+ ): Promise<{ action: "created" | "merged" | "failed" }> => {
+ 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();
+ }
+ return { action: result.action };
+ } catch (error) {
+ console.error("[tab:tear-off-end] 操作失败:", error);
+ return { action: "failed" };
+ }
+ }
+ );
+
+ // ── Tab 拖拽分离:取消跟随(指针回到窗口内时取消分离)──
+ ipcMain.handle("tab:tear-off-cancel", async (): Promise => {
+ try {
+ await cancelDragFollow();
+ return true;
+ } catch (error) {
+ console.error("[tab:tear-off-cancel] 取消失败:", error);
+ return false;
+ }
+ });
+
+ // ── 跨窗口文件去重:检查文件是否已在某个窗口打开 ────────
+ ipcMain.handle(
+ "file:focus-if-open",
+ async (event, filePath: string): Promise<{ found: boolean }> => {
+ const sourceWin = BrowserWindow.fromWebContents(event.sender);
+ const targetWin = findWindowWithFile(filePath, sourceWin?.id);
+ if (!targetWin) return { found: false };
+
+ targetWin.webContents.send("tab:activate-file", filePath);
+ targetWin.focus();
+ return { found: true };
+ }
+ );
+
+ // ── 新窗口获取初始 Tab 数据 ─────────────────────────────
+ ipcMain.handle("tab:get-init-data", (event) => {
+ const data = consumePendingTabData(event.sender.id);
+ // 预设未保存状态,避免窗口初始化前被关闭时误认为“已保存”
+ if (data?.isModified) {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ if (win) windowSaveState.set(win.id, false);
+ }
+ return data;
+ });
+
+ // ── 获取当前窗口边界(用于渲染进程判断拖拽是否出界)────
+ ipcMain.handle("window:get-bounds", (event) => {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ if (!win) return null;
+ return win.getBounds();
+ });
+
+ // ── 单 Tab 窗口拖拽:直接移动窗口 ─────────────────────
+ ipcMain.on(
+ "window:start-drag",
+ (event, tabData: TearOffTabData, offsetX: number, offsetY: number) => {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ if (!win || win.isDestroyed()) return;
+ // 记录 drag offset 并启动窗口位置跟随定时器
+ startWindowDrag(win, tabData, offsetX, offsetY);
+ }
+ );
+
+ ipcMain.on("window:stop-drag", () => {
+ stopWindowDrag();
+ });
+
+ // ── 单 Tab 窗口松手:判断是否合并到目标窗口 ─────────────
+ ipcMain.handle(
+ "window:drop-merge",
+ async (
+ event,
+ tabData: TearOffTabData,
+ screenX: number,
+ screenY: number
+ ): Promise<{ action: "merged" | "none" }> => {
+ const sourceWin = BrowserWindow.fromWebContents(event.sender);
+ const previewTarget = finalizeWindowDragMerge();
+ if (previewTarget && !previewTarget.isDestroyed()) {
+ previewTarget.focus();
+ return { action: "merged" };
+ }
+ // 查找目标窗口
+ for (const win of getEditorWindows()) {
+ if (win === sourceWin || win.isDestroyed()) continue;
+ const { x, y, width, height } = win.getBounds();
+ if (screenX >= x && screenX <= x + width && screenY >= y && screenY <= y + height) {
+ win.webContents.send("tab:merge-in", tabData);
+ win.focus();
+ return { action: "merged" };
+ }
+ }
+ clearWindowDragPreview();
+ return { action: "none" };
+ }
+ );
+
// 通过文件路径读取 Markdown 文件(用于拖拽)
ipcMain.handle("file:readByPath", async (_event, filePath: string) => {
try {
@@ -592,7 +770,11 @@ export function registerGlobalIpcHandlers() {
});
// 监听文件变化
- ipcMain.on("file:watch", (_event, filePaths: string[]) => {
+ ipcMain.on("file:watch", (event, filePaths: string[]) => {
+ // 更新主进程的文件打开索引(用于跨窗口文件去重 O(1) 查询)
+ const win = BrowserWindow.fromWebContents(event.sender);
+ if (win) updateWindowOpenFiles(win.id, filePaths);
+
// 先差异对比
const newFiles = filePaths.filter((filePath) => !watchedFiles.has(filePath));
const removedFiles = Array.from(watchedFiles).filter(
@@ -610,11 +792,12 @@ export function registerGlobalIpcHandlers() {
persistent: true,
});
- // 设置文件变化监听
+ // 设置文件变化监听 —— 广播到所有编辑器窗口
watcher.on("change", (filePath) => {
- const mainWindow = BrowserWindow.getAllWindows()[0];
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send("file:changed", filePath);
+ for (const editorWin of getEditorWindows()) {
+ if (!editorWin.isDestroyed()) {
+ editorWin.webContents.send("file:changed", filePath);
+ }
}
});
}
@@ -633,26 +816,32 @@ export function registerGlobalIpcHandlers() {
});
}
export function close(win: Electron.BrowserWindow) {
- // 防止重复调用
- if (isQuitting) {
- return;
- }
+ // 如果窗口已销毁或已在关闭流程中,跳过
+ if (win.isDestroyed() || windowClosingSet.has(win.id)) return;
+
+ const isSaved = windowSaveState.get(win.id) ?? true;
if (isSaved) {
- isQuitting = true;
+ windowClosingSet.add(win.id);
win.close();
- app.quit();
+ cleanupWindowState(win.id);
+ // 如果所有窗口都关了(非 macOS),退出应用
+ const remaining = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
+ if (remaining.length === 0 && process.platform !== "darwin") {
+ app.quit();
+ }
} else {
- // 检查窗口是否仍然有效
- if (win && !win.isDestroyed()) {
- // 发送事件到渲染进程显示前端弹窗
+ // 有未保存内容,通知渲染进程弹出确认框
+ if (!win.isDestroyed()) {
win.webContents.send("close:confirm");
}
}
}
export function getIsQuitting() {
- return isQuitting;
+ // 兼容旧逻辑:当所有窗口都在关闭时视为正在退出
+ const allWindows = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
+ return allWindows.length === 0 || allWindows.every((w) => windowClosingSet.has(w.id));
}
export function isFileReadOnly(filePath: string): boolean {
// 先检测是否可写(跨平台)
diff --git a/src/main/windowManager.ts b/src/main/windowManager.ts
new file mode 100644
index 0000000..5ad7bcf
--- /dev/null
+++ b/src/main/windowManager.ts
@@ -0,0 +1,520 @@
+/**
+ * 窗口管理器 - 支持多窗口 Tab 拖拽分离
+ *
+ * 职责:
+ * 1. 跟踪所有编辑器窗口
+ * 2. 创建配置统一的编辑器窗口
+ * 3. 管理 Tab 拖拽分离的数据传递
+ */
+
+import * as path from "node:path";
+import { BrowserWindow, screen, shell } from "electron";
+import type { TearOffTabData } from "../shared/types/tearoff";
+
+export type { TearOffTabData };
+
+export interface CreateWindowOptions {
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+ tabData?: TearOffTabData;
+ /** 快速创建:不等待页面加载完成,不自动打开 devtools */
+ fastCreate?: boolean;
+ /** 是否将 x,y 视为中心点 (如果是 false,则 x,y 为左上角坐标) */
+ center?: boolean;
+}
+
+// ─── 状态 ────────────────────────────────────────────────────
+
+/** 所有活跃的编辑器窗口 */
+const editorWindows = new Set();
+
+/** 尚未被新窗口消费的 Tab 数据(按 webContentsId 索引) */
+const pendingTabData = new Map();
+
+/** 每个窗口当前打开的文件路径(用于跨窗口文件去重 O(1) 查询) */
+const windowOpenFiles = new Map>();
+
+/** 主窗口引用(macOS 需要区分主窗口与普通窗口) */
+let mainWindow: BrowserWindow | null = null;
+
+// ─── 查询 ────────────────────────────────────────────────────
+
+export function getEditorWindows(): ReadonlySet {
+ return editorWindows;
+}
+
+export function getMainWindow(): BrowserWindow | null {
+ return mainWindow;
+}
+
+export function isMainWindow(win: BrowserWindow): boolean {
+ return win === mainWindow;
+}
+
+/** 更新指定窗口的打开文件列表 */
+export function updateWindowOpenFiles(winId: number, filePaths: string[]): void {
+ windowOpenFiles.set(winId, new Set(filePaths));
+}
+
+/** 查找已打开指定文件的窗口(O(1) 查询,排除指定窗口) */
+export function findWindowWithFile(filePath: string, excludeWinId?: number): BrowserWindow | null {
+ for (const [winId, files] of windowOpenFiles) {
+ if (winId === excludeWinId) continue;
+ if (!files.has(filePath)) continue;
+ const win = BrowserWindow.fromId(winId);
+ if (win && !win.isDestroyed()) return win;
+ }
+ return null;
+}
+
+/**
+ * 根据屏幕坐标查找对应位置的编辑器窗口
+ * @param screenX 屏幕 X 坐标
+ * @param screenY 屏幕 Y 坐标
+ * @param excludeWin 排除的窗口(通常是发起方自身)
+ */
+export function findWindowAtPosition(
+ screenX: number,
+ screenY: number,
+ excludeWin?: BrowserWindow | null
+): BrowserWindow | null {
+ for (const win of editorWindows) {
+ if (win === excludeWin || win.isDestroyed()) continue;
+ const { x, y, width, height } = win.getBounds();
+ if (screenX >= x && screenX <= x + width && screenY >= y && screenY <= y + height) {
+ return win;
+ }
+ }
+ return null;
+}
+
+// ─── 窗口追踪 ───────────────────────────────────────────────
+
+export function trackWindow(win: BrowserWindow, isMain = false): void {
+ editorWindows.add(win);
+ if (isMain) mainWindow = win;
+
+ // 提前缓存 —— closed 事件触发时窗口已销毁,无法再访问属性
+ const webContentsId = win.webContents.id;
+ const winId = win.id;
+
+ win.on("closed", () => {
+ editorWindows.delete(win);
+ pendingTabData.delete(webContentsId);
+ windowOpenFiles.delete(winId);
+ if (win === mainWindow) mainWindow = null;
+ });
+}
+
+// ─── 待消费 Tab 数据 ─────────────────────────────────────────
+
+export function setPendingTabData(webContentsId: number, data: TearOffTabData): void {
+ pendingTabData.set(webContentsId, data);
+}
+
+/** 取出并删除,确保只消费一次 */
+export function consumePendingTabData(webContentsId: number): TearOffTabData | null {
+ const data = pendingTabData.get(webContentsId);
+ if (data) pendingTabData.delete(webContentsId);
+ return data ?? null;
+}
+
+// ─── 窗口创建 ────────────────────────────────────────────────
+
+/**
+ * 创建一个新的编辑器窗口
+ * - 与主窗口共享相同的 webPreferences、外链处理等配置
+ * - 可选传入 tabData,窗口加载完成后由渲染进程通过 IPC 取回
+ */
+export async function createEditorWindow(
+ options: CreateWindowOptions = {}
+): Promise {
+ const { x, y, width = 1000, height = 700, tabData, fastCreate = false, center = true } = options;
+
+ const winOptions: Electron.BrowserWindowConstructorOptions = {
+ width,
+ height,
+ minWidth: 800,
+ minHeight: 600,
+ frame: false,
+ titleBarStyle: "hidden",
+ show: !fastCreate, // 拖拽跟随窗口初始不显示,避免抢夺焦点
+ icon: path.join(__dirname, "../assets/icons/milkup.ico"),
+ webPreferences: {
+ sandbox: false,
+ preload: path.resolve(__dirname, "../../dist-electron/preload.js"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ webSecurity: false,
+ },
+ };
+
+ // 设置窗口位置
+ if (x !== undefined && y !== undefined) {
+ if (center) {
+ winOptions.x = Math.round(x - width / 2);
+ winOptions.y = Math.round(y - 20);
+ } else {
+ winOptions.x = Math.round(x);
+ winOptions.y = Math.round(y);
+ }
+ }
+
+ const win = new BrowserWindow(winOptions);
+ trackWindow(win);
+
+ // 存储待消费的 Tab 数据
+ if (tabData) {
+ setPendingTabData(win.webContents.id, tabData);
+ }
+
+ // ── 外链处理(与主窗口一致)──────────────────────────────
+ win.webContents.setWindowOpenHandler(({ url }) => {
+ if (url.startsWith("https:") || url.startsWith("http:")) {
+ shell.openExternal(url);
+ }
+ return { action: "deny" };
+ });
+
+ win.webContents.on("will-navigate", (event, url) => {
+ if (process.env.VITE_DEV_SERVER_URL && url.startsWith(process.env.VITE_DEV_SERVER_URL)) {
+ return;
+ }
+ if (url.startsWith("https:") || url.startsWith("http:")) {
+ event.preventDefault();
+ shell.openExternal(url);
+ }
+ });
+
+ // ── 加载页面 ──────────────────────────────────────────────
+ const indexPath = path.join(__dirname, "../../dist", "index.html");
+
+ if (process.env.VITE_DEV_SERVER_URL) {
+ if (fastCreate) {
+ win.loadURL(process.env.VITE_DEV_SERVER_URL);
+ } else {
+ await win.loadURL(process.env.VITE_DEV_SERVER_URL);
+ }
+ } else {
+ if (fastCreate) {
+ win.loadFile(indexPath);
+ } else {
+ await win.loadFile(indexPath);
+ }
+ }
+
+ if (process.env.VITE_DEV_SERVER_URL && !fastCreate) {
+ win.webContents.openDevTools();
+ }
+
+ // fastCreate 模式下显示窗口但不抢焦点
+ if (fastCreate) {
+ win.showInactive();
+ }
+
+ return win;
+}
+
+// ─── 拖拽跟随(创建新窗口并跟随光标直到松手)───────────
+
+// ─── 合并预览(拖拽悬停即合并,离开撤销)─────────────────
+
+const mergePreviewTargets = new Map();
+
+function updateMergePreview(
+ sourceWinId: number,
+ tabData: TearOffTabData,
+ screenX: number,
+ screenY: number,
+ excludeWins: BrowserWindow[] = []
+): BrowserWindow | null {
+ let target: BrowserWindow | null = null;
+
+ for (const win of editorWindows) {
+ if (win.isDestroyed()) continue;
+ if (win.id === sourceWinId) continue;
+ if (excludeWins.includes(win)) continue;
+ const { x, y, width, height } = win.getBounds();
+ if (screenX >= x && screenX <= x + width && screenY >= y && screenY <= y + height) {
+ target = win;
+ break;
+ }
+ }
+
+ const prev = mergePreviewTargets.get(sourceWinId) ?? null;
+ if (prev?.id === target?.id) return target;
+
+ if (prev && !prev.isDestroyed()) {
+ prev.webContents.send("tab:merge-preview-cancel");
+ }
+ if (target && !target.isDestroyed()) {
+ target.webContents.send("tab:merge-preview", tabData, screenX, screenY);
+ }
+
+ mergePreviewTargets.set(sourceWinId, target ?? null);
+ return target;
+}
+
+function finalizeMergePreview(sourceWinId: number): BrowserWindow | null {
+ const target = mergePreviewTargets.get(sourceWinId) ?? null;
+ if (target && !target.isDestroyed()) {
+ target.webContents.send("tab:merge-preview-finalize");
+ }
+ mergePreviewTargets.delete(sourceWinId);
+ return target ?? null;
+}
+
+function clearMergePreview(sourceWinId: number): void {
+ const target = mergePreviewTargets.get(sourceWinId) ?? null;
+ if (target && !target.isDestroyed()) {
+ target.webContents.send("tab:merge-preview-cancel");
+ }
+ mergePreviewTargets.delete(sourceWinId);
+}
+
+// ─── 单 Tab 窗口拖拽(直接移动整个窗口)─────────────────
+
+let windowDragInterval: ReturnType | null = null;
+let windowDragSourceId: number | null = null;
+let windowDragTabData: TearOffTabData | null = null;
+
+/**
+ * 开始以 ~60fps 让窗口跟随光标
+ * @param offsetX 鼠标相对窗口左上角的 X 偏移
+ * @param offsetY 鼠标相对窗口左上角的 Y 偏移
+ */
+export function startWindowDrag(
+ win: BrowserWindow,
+ tabData: TearOffTabData,
+ offsetX: number,
+ offsetY: number
+): void {
+ stopWindowDrag();
+ windowDragSourceId = win.id;
+ windowDragTabData = tabData;
+ let prevCX = -1,
+ prevCY = -1;
+ windowDragInterval = setInterval(() => {
+ if (!win || win.isDestroyed()) {
+ stopWindowDrag();
+ return;
+ }
+ const cursor = screen.getCursorScreenPoint();
+ 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));
+
+ if (windowDragSourceId && windowDragTabData) {
+ updateMergePreview(windowDragSourceId, windowDragTabData, cursor.x, cursor.y, [win]);
+ }
+ }, 16);
+}
+
+export function stopWindowDrag(): void {
+ if (windowDragInterval) {
+ clearInterval(windowDragInterval);
+ windowDragInterval = null;
+ }
+}
+
+export function finalizeWindowDragMerge(): BrowserWindow | null {
+ if (!windowDragSourceId) return null;
+ const target = finalizeMergePreview(windowDragSourceId);
+ windowDragSourceId = null;
+ windowDragTabData = null;
+ return target;
+}
+
+export function clearWindowDragPreview(): void {
+ if (windowDragSourceId) {
+ clearMergePreview(windowDragSourceId);
+ }
+ windowDragSourceId = null;
+ windowDragTabData = null;
+}
+
+// ─── 多 Tab 拖拽跟随(创建新窗口并跟随光标直到松手)─────
+
+interface DragFollowState {
+ interval: ReturnType;
+ window: BrowserWindow;
+ tabData: TearOffTabData;
+ sourceWinId: number | null;
+ hiddenForPreview: boolean;
+}
+
+let dragFollowState: DragFollowState | null = null;
+let dragFollowReady: Promise | null = null;
+
+/**
+ * 开始拖拽跟随:立即创建新窗口并以 ~60fps 跟随光标移动
+ * 渲染进程 fire-and-forget 调用,无需等待返回
+ */
+export function startDragFollow(
+ tabData: TearOffTabData,
+ screenX: number,
+ screenY: number,
+ offsetX: number,
+ offsetY: number,
+ sourceWin: BrowserWindow | null
+): void {
+ cleanupDragFollow();
+
+ dragFollowReady = (async () => {
+ // 初始位置:根据 offset 计算窗口位置
+ // 从 screenX/Y 中减去 offset,使窗口内的 tab 相对鼠标位置不变
+ const initX = Math.round(screenX - offsetX);
+ const initY = Math.round(screenY - offsetY);
+
+ const win = await createEditorWindow({
+ x: initX,
+ y: initY,
+ tabData,
+ fastCreate: true,
+ center: false,
+ });
+
+ // 如果在等待期间已被取消
+ if (!dragFollowReady) return;
+
+ let prevCX = -1,
+ prevCY = -1;
+ const interval = setInterval(() => {
+ if (!win || win.isDestroyed()) {
+ cleanupDragFollow();
+ return;
+ }
+ const cursor = screen.getCursorScreenPoint();
+ 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));
+
+ if (dragFollowState) {
+ const sourceId = dragFollowState.sourceWinId;
+ if (sourceId) {
+ const target = updateMergePreview(sourceId, tabData, cursor.x, cursor.y, [win]);
+ if (target && !dragFollowState.hiddenForPreview) {
+ win.hide();
+ dragFollowState.hiddenForPreview = true;
+ } else if (!target && dragFollowState.hiddenForPreview) {
+ win.showInactive();
+ dragFollowState.hiddenForPreview = false;
+ }
+ }
+ }
+ }, 16);
+
+ dragFollowState = {
+ interval,
+ window: win,
+ tabData,
+ sourceWinId: sourceWin?.id ?? null,
+ hiddenForPreview: false,
+ };
+ })();
+}
+
+/** 内部清理(不关闭新窗口) */
+function cleanupDragFollow(): void {
+ if (dragFollowState) {
+ clearInterval(dragFollowState.interval);
+ if (dragFollowState.sourceWinId) {
+ clearMergePreview(dragFollowState.sourceWinId);
+ }
+ dragFollowState = null;
+ }
+ dragFollowReady = null;
+}
+
+/**
+ * 取消拖拽跟随:停止跟随并关闭已创建的新窗口
+ * 当用户把 Tab 拖回原窗口时由渲染进程调用
+ */
+export async function cancelDragFollow(): Promise {
+ // 等待窗口创建完成(可能仍在创建中)
+ if (dragFollowReady) {
+ try {
+ await dragFollowReady;
+ } catch {
+ cleanupDragFollow();
+ return;
+ }
+ }
+
+ if (!dragFollowState) {
+ dragFollowReady = null;
+ return;
+ }
+
+ const { interval, window: newWin, sourceWinId } = dragFollowState;
+ clearInterval(interval);
+ if (sourceWinId) {
+ clearMergePreview(sourceWinId);
+ }
+ dragFollowState = null;
+ dragFollowReady = null;
+
+ // 关闭已创建的新窗口
+ if (newWin && !newWin.isDestroyed()) {
+ newWin.close();
+ }
+}
+
+/**
+ * 完成拖拽跟随:停止跟随、判断合并或保留新窗口
+ * 由渲染进程在 SortableJS onEnd(鼠标松开)时调用
+ */
+export async function finalizeDragFollow(
+ screenX: number,
+ screenY: number,
+ sourceWin: BrowserWindow | null
+): Promise<{ action: "created" | "merged" | "failed"; newWin?: BrowserWindow }> {
+ // 等待窗口创建完成(快速松手时可能仍在创建中)
+ if (dragFollowReady) {
+ try {
+ await dragFollowReady;
+ } catch {
+ cleanupDragFollow();
+ return { action: "failed" };
+ }
+ }
+
+ if (!dragFollowState) return { action: "failed" };
+
+ const { interval, window: newWin, tabData, sourceWinId } = dragFollowState;
+ clearInterval(interval);
+ dragFollowState = null;
+ dragFollowReady = null;
+
+ if (newWin.isDestroyed()) return { action: "failed" };
+
+ // 若悬停窗口已产生预览,立即完成合并
+ if (sourceWinId) {
+ const target = finalizeMergePreview(sourceWinId);
+ if (target && !target.isDestroyed()) {
+ target.focus();
+ newWin.close();
+ return { action: "merged" };
+ }
+ }
+
+ // 查找光标下的目标窗口(排除新窗口与源窗口)
+ for (const win of editorWindows) {
+ if (win === sourceWin || win === newWin || win.isDestroyed()) continue;
+ const { x, y, width, height } = win.getBounds();
+ if (screenX >= x && screenX <= x + width && screenY >= y && screenY <= y + height) {
+ // ── 合并到已有窗口 ──
+ win.webContents.send("tab:merge-in", tabData);
+ win.focus();
+ newWin.close();
+ return { action: "merged" };
+ }
+ }
+
+ // ── 无目标窗口 → 保留新窗口在当前位置 ──
+ return { action: "created", newWin };
+}
diff --git a/src/preload.ts b/src/preload.ts
index d249e5e..e984f31 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -83,6 +83,28 @@ contextBridge.exposeInMainWorld("electronAPI", {
platform: process.platform,
rendererReady: () => ipcRenderer.send("renderer-ready"),
+ // Tab 拖拽分离
+ tearOffTabStart: (
+ tabData: any,
+ screenX: number,
+ screenY: number,
+ offsetX: number,
+ offsetY: number
+ ) => ipcRenderer.invoke("tab:tear-off-start", tabData, screenX, screenY, offsetX, offsetY),
+ tearOffTabEnd: (screenX: number, screenY: number) =>
+ ipcRenderer.invoke("tab:tear-off-end", screenX, screenY),
+ tearOffTabCancel: () => ipcRenderer.invoke("tab:tear-off-cancel"),
+ focusFileIfOpen: (filePath: string) => ipcRenderer.invoke("file:focus-if-open", filePath),
+ getInitialTabData: () => ipcRenderer.invoke("tab:get-init-data"),
+ getWindowBounds: () => ipcRenderer.invoke("window:get-bounds"),
+
+ // 单 Tab 窗口拖拽
+ startWindowDrag: (tabData: any, offsetX: number, offsetY: number) =>
+ ipcRenderer.send("window:start-drag", tabData, offsetX, offsetY),
+ stopWindowDrag: () => ipcRenderer.send("window:stop-drag"),
+ dropMerge: (tabData: any, screenX: number, screenY: number) =>
+ ipcRenderer.invoke("window:drop-merge", tabData, screenX, screenY),
+
// 自动更新 API
checkForUpdates: () => ipcRenderer.invoke("update:check"),
downloadUpdate: () => ipcRenderer.invoke("update:download"),
diff --git a/src/renderer/components/workspace/TabBar.vue b/src/renderer/components/workspace/TabBar.vue
index f032e6d..65f8cc8 100644
--- a/src/renderer/components/workspace/TabBar.vue
+++ b/src/renderer/components/workspace/TabBar.vue
@@ -14,6 +14,12 @@ const {
setupTabScrollListener,
cleanupInertiaScroll,
reorderTabs,
+ startTearOff,
+ endTearOff,
+ cancelTearOff,
+ isSingleTab,
+ startSingleTabDrag,
+ endSingleTabDrag,
} = useTab();
const { createNewFile } = useFile();
@@ -49,8 +55,168 @@ async function handleCloseTab(id: string, event: Event) {
closeWithConfirm(id);
}
-// 处理拖动排序
+// ── Tab 拖拽分离检测 ────────────────────────────────────────
+
+/** 鼠标超出窗口边界的阈值(px),避免微小越界误触发 */
+const TEAR_OFF_THRESHOLD = 30;
+
+/** 拖拽期间的状态 */
+let dragState: {
+ tabId: string | null;
+ tearOffTriggered: boolean; // 多 Tab:已触发 tear-off
+ singleTabDragActive: boolean; // 单 Tab:窗口拖拽激活
+ lastScreenX: number;
+ lastScreenY: number;
+ initialOffsetX: number; // 鼠标距离 Tab 左上角的 X 偏移
+ initialOffsetY: number; // 鼠标距离 Tab 左上角的 Y 偏移
+} = {
+ tabId: null,
+ tearOffTriggered: false,
+ singleTabDragActive: false,
+ lastScreenX: 0,
+ lastScreenY: 0,
+ initialOffsetX: 0,
+ initialOffsetY: 0,
+};
+
+/** 缓存的窗口边界 */
+let cachedBounds: { 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;
+ return (
+ screenX < x - TEAR_OFF_THRESHOLD ||
+ screenX > x + width + TEAR_OFF_THRESHOLD ||
+ screenY < y - TEAR_OFF_THRESHOLD ||
+ screenY > y + height + TEAR_OFF_THRESHOLD
+ );
+}
+
+/**
+ * 拖拽期间的 pointer 位置追踪
+ *
+ * 多 Tab:指针离开窗口 → 立即创建新窗口跟随光标(fire-and-forget)
+ * 单 Tab:直接进入窗口拖拽模式(由主进程 setInterval 驱动位置更新)
+ */
+function onDragPointerMove(e: PointerEvent) {
+ dragState.lastScreenX = e.screenX;
+ dragState.lastScreenY = e.screenY;
+
+ // 单 Tab 模式已激活,不需要做别的
+ if (dragState.singleTabDragActive) return;
+
+ if (!dragState.tabId) return;
+
+ if (isSingleTab.value) {
+ // ── 单 Tab:立即开始窗口拖拽 ──
+ if (!cachedBounds) return;
+ dragState.singleTabDragActive = true;
+ const offsetX = e.screenX - cachedBounds.x;
+ const offsetY = e.screenY - cachedBounds.y;
+ startSingleTabDrag(dragState.tabId, offsetX, offsetY);
+ document.body.classList.add("tab-torn-off");
+ return;
+ }
+
+ // 多 Tab 模式:检测拖拽分离与回拖
+ if (dragState.tearOffTriggered) {
+ // 已触发 tear-off,检查指针是否回到窗口内
+ if (!isOutsideWindow(e.screenX, e.screenY)) {
+ // 指针回到窗口内 → 取消分离,关闭跟随窗口
+ dragState.tearOffTriggered = false;
+ document.body.classList.remove("tab-torn-off");
+ cancelTearOff();
+ }
+ return;
+ }
+
+ if (isOutsideWindow(e.screenX, e.screenY)) {
+ // ── 多 Tab:指针离开窗口 → 开始分离跟随 ──
+ dragState.tearOffTriggered = true;
+ document.body.classList.add("tab-torn-off");
+ startTearOff(
+ dragState.tabId,
+ e.screenX,
+ e.screenY,
+ dragState.initialOffsetX,
+ dragState.initialOffsetY
+ );
+ }
+}
+
+/** SortableJS onStart:记录拖拽的 Tab 并开始追踪指针 */
+function handleDragStart(event: any) {
+ const tabId = event.item?.dataset?.tabId ?? null;
+
+ // 计算初始点击位置相对 Tab 元素的偏移
+ let initialOffsetX = 0;
+ let initialOffsetY = 0;
+ if (event.originalEvent && event.item) {
+ const rect = event.item.getBoundingClientRect();
+ const clientX = event.originalEvent.clientX ?? event.originalEvent.touches?.[0]?.clientX ?? 0;
+ const clientY = event.originalEvent.clientY ?? event.originalEvent.touches?.[0]?.clientY ?? 0;
+ if (clientX && clientY) {
+ initialOffsetX = clientX - rect.left;
+ initialOffsetY = clientY - rect.top;
+ }
+ }
+
+ dragState = {
+ tabId,
+ tearOffTriggered: false,
+ singleTabDragActive: false,
+ lastScreenX: 0,
+ lastScreenY: 0,
+ initialOffsetX,
+ initialOffsetY,
+ };
+
+ // 非阻塞获取窗口边界,tear-off 检测在边界就绪后自动激活
+ window.electronAPI.getWindowBounds().then((bounds) => {
+ cachedBounds = bounds;
+ });
+
+ document.addEventListener("pointermove", onDragPointerMove, { capture: true });
+}
+
+/**
+ * SortableJS onEnd:鼠标松开
+ * - 单 Tab 窗口拖拽 → 停止拖拽,判断合并
+ * - 多 Tab tear-off → 停止跟随,判断合并/保留
+ * - 正常拖拽 → 重排
+ */
function handleDragEnd(event: { oldIndex: number; newIndex: number }) {
+ document.removeEventListener("pointermove", onDragPointerMove, { capture: true });
+ document.body.classList.remove("tab-torn-off");
+
+ const { tabId, tearOffTriggered, singleTabDragActive, lastScreenX, lastScreenY } = dragState;
+ dragState = {
+ tabId: null,
+ tearOffTriggered: false,
+ singleTabDragActive: false,
+ lastScreenX: 0,
+ lastScreenY: 0,
+ initialOffsetX: 0,
+ initialOffsetY: 0,
+ };
+ cachedBounds = null;
+
+ if (singleTabDragActive) {
+ endSingleTabDrag(lastScreenX, lastScreenY);
+ return;
+ }
+
+ if (tearOffTriggered && tabId) {
+ // 必须通过数据驱动视图更新,确保 SortableJS 内部状态重置
+ // 使用 requestAnimationFrame 确保 UI 更新后再执行结束逻辑
+ requestAnimationFrame(() => {
+ endTearOff(tabId, lastScreenX, lastScreenY);
+ });
+ return;
+ }
+
reorderTabs(event.oldIndex, event.newIndex);
}
@@ -93,6 +259,8 @@ onUnmounted(() => {
}
// 移除全局键盘事件监听器
window.removeEventListener("keydown", handleCloseTabShortcut);
+ // 清理拖拽追踪
+ document.removeEventListener("pointermove", onDragPointerMove, { capture: true });
});
@@ -103,7 +271,22 @@ onUnmounted(() => {
:class="{ 'offset-right': shouldOffsetTabBar }"
>
{
v-for="tab in formattedTabs"
:key="tab.id"
class="tabItem"
- :class="{ active: activeTabId === tab.id }"
+ :class="{ active: activeTabId === tab.id, 'merge-preview': tab.isMergePreview }"
:data-tab-id="tab.id"
@click="handleTabClick(tab.id)"
>
@@ -269,6 +452,18 @@ onUnmounted(() => {
}
}
+ &.merge-preview {
+ background: var(--background-color-2);
+ opacity: 0.6;
+ border: 2px dashed var(--active-color);
+ box-shadow: none;
+
+ p,
+ .closeIcon {
+ opacity: 0.5;
+ }
+ }
+
&:hover {
z-index: 1;
@@ -385,6 +580,80 @@ onUnmounted(() => {
background: var(--background-color-2);
}
+/* forceFallback 克隆体:保持原始 tab 样式,仅隐藏无关装饰 */
+:global(.tab-drag-fallback) {
+ /* 恢复丢失的 scoped 样式 */
+ position: fixed; /* fallbackOnBody 时是 fixed */
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--background-color-1);
+ gap: 8px;
+ border-radius: 6px 6px 0 0;
+ padding: 0 10px;
+ box-sizing: border-box;
+ width: 150px;
+ height: 30px; /* tabItem 高度由父级决定,这里显式指定 */
+
+ z-index: 99999;
+ pointer-events: none;
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
+ opacity: 0.95;
+ cursor: grabbing;
+}
+
+:global(.tab-drag-fallback p) {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-color-1); /* 激活态颜色 */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+}
+
+:global(.tab-drag-fallback .closeIcon) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+:global(.tab-drag-fallback .closeIcon span) {
+ font-size: 12px;
+ line-height: 28px;
+ color: var(--text-color-1);
+}
+
+:global(.tab-drag-fallback .pre),
+:global(.tab-drag-fallback .after) {
+ position: absolute;
+
+ bottom: -10px;
+ width: 10px;
+ height: 100%;
+ fill: var(--background-color-2);
+ animation: fadeIn 0.3s ease;
+ transition: all 0.3s ease;
+
+ &.active {
+ fill: var(--background-color-1);
+ }
+}
+:global(.tab-drag-fallback .pre) {
+ left: -10px;
+}
+:global(.tab-drag-fallback .after) {
+ right: -10px;
+}
+
+/* Tab 拖拽到窗口外时隐藏 SortableJS 克隆体(此时新窗口已在跟随光标) */
+:global(body.tab-torn-off .tab-drag-fallback) {
+ opacity: 0;
+ transition: none;
+}
+
@keyframes fadeIn {
from {
opacity: 0;
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index c5858cb..704d524 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -1,8 +1,5 @@
-interface FileTraitsDTO {
- hasBOM: boolean;
- lineEnding: "crlf" | "lf";
- hasTrailingNewline: boolean;
-}
+type FileTraitsDTO = import("../shared/types/tearoff").FileTraitsDTO;
+type TearOffTabData = import("../shared/types/tearoff").TearOffTabData;
interface Window {
electronAPI: {
@@ -70,6 +67,30 @@ interface Window {
saveCustomTheme: (theme: any) => void;
platform: NodeJS.Platform;
rendererReady: () => void;
+ // Tab 拖拽分离
+ tearOffTabStart: (
+ tabData: TearOffTabData,
+ screenX: number,
+ screenY: number,
+ offsetX: number,
+ offsetY: number
+ ) => Promise;
+ tearOffTabEnd: (
+ screenX: number,
+ screenY: number
+ ) => Promise<{ action: "created" | "merged" | "failed" }>;
+ tearOffTabCancel: () => Promise;
+ focusFileIfOpen: (filePath: string) => Promise<{ found: boolean }>;
+ getInitialTabData: () => Promise;
+ getWindowBounds: () => Promise<{ x: number; y: number; width: number; height: number } | null>;
+ // 单 Tab 窗口拖拽
+ startWindowDrag: (tabData: TearOffTabData, offsetX: number, offsetY: number) => void;
+ stopWindowDrag: () => void;
+ dropMerge: (
+ tabData: TearOffTabData,
+ screenX: number,
+ screenY: number
+ ) => Promise<{ action: "merged" | "none" }>;
// 自动更新相关
checkForUpdates: () => Promise;
downloadUpdate: () => Promise;
diff --git a/src/renderer/hooks/useFile.ts b/src/renderer/hooks/useFile.ts
index cd7d261..5194ecd 100644
--- a/src/renderer/hooks/useFile.ts
+++ b/src/renderer/hooks/useFile.ts
@@ -27,7 +27,7 @@ async function onOpen(result?: { filePath: string; content: string } | null) {
filePath.value = result.filePath;
const content = result.content;
- // 检查文件是否已打开
+ // 检查文件是否已在当前窗口打开
const existingTab = isFileAlreadyOpen(result.filePath);
if (existingTab) {
await switchToTab(existingTab.id);
@@ -40,6 +40,12 @@ async function onOpen(result?: { filePath: string; content: string } | null) {
return;
}
+ // 检查文件是否已在其他窗口打开
+ try {
+ const crossResult = await window.electronAPI.focusFileIfOpen(result.filePath);
+ if (crossResult.found) return;
+ } catch {}
+
// 如果当前活跃tab是未修改的新标签页,复用它
const current = currentTab.value;
if (current && current.filePath === null && !current.isModified) {
@@ -253,7 +259,7 @@ export default function useFile() {
currentTab.value!.readOnly = fileContent.readOnly || false;
currentTab.value!.fileTraits = fileContent.fileTraits;
} else {
- // 检查文件是否已打开
+ // 检查文件是否已在当前窗口打开
const existing = isFileAlreadyOpen(fileContent.filePath);
if (existing) {
await switchToTab(existing.id);
@@ -261,6 +267,14 @@ export default function useFile() {
filePath.value = fileContent.filePath;
originalContent.value = existing.originalContent;
} else {
+ // 检查文件是否已在其他窗口打开
+ try {
+ const crossResult = await window.electronAPI.focusFileIfOpen(fileContent.filePath);
+ if (crossResult.found) {
+ updateTitle();
+ return;
+ }
+ } catch {}
let tab: Tab;
const current = currentTab.value;
// 复用空标签页
@@ -341,7 +355,7 @@ export default function useFile() {
// 注册启动时文件打开监听
window.electronAPI?.onOpenFileAtLaunch?.(
async ({ filePath: launchFilePath, content, fileTraits }) => {
- // 检查文件是否已打开
+ // 检查文件是否已在当前窗口打开
const existing = isFileAlreadyOpen(launchFilePath);
if (existing) {
await switchToTab(existing.id);
@@ -355,6 +369,12 @@ export default function useFile() {
return;
}
+ // 检查文件是否已在其他窗口打开
+ try {
+ const crossResult = await window.electronAPI.focusFileIfOpen(launchFilePath);
+ if (crossResult.found) return;
+ } catch {}
+
let tab: Tab;
const current = currentTab.value;
// 复用空标签页
diff --git a/src/renderer/hooks/useTab.ts b/src/renderer/hooks/useTab.ts
index 3890886..44f1387 100644
--- a/src/renderer/hooks/useTab.ts
+++ b/src/renderer/hooks/useTab.ts
@@ -33,7 +33,58 @@ function scheduleNewlyLoadedCleanup(tabId: string) {
const defaultName = "Untitled";
const defaultTabUUid = randomUUID();
-// 初始化时创建一个默认的未命名文档
+
+// ── 窗口初始化逻辑 ─────────────────────────────────────────
+// 如果是由 Tab 拖拽分离创建的新窗口,使用传入的 Tab 数据替换默认 Tab
+let _tearOffInitPromise: Promise | null = null;
+
+async function initFromTearOff(): Promise {
+ try {
+ const tabData: TearOffTabData | null = await window.electronAPI?.getInitialTabData();
+ if (!tabData) return false;
+
+ // 用分离的 Tab 数据替换默认空白 Tab
+ // 已修改的 Tab 不能标记 isNewlyLoaded,否则编辑器归一化会覆盖 originalContent 并重置 isModified
+ const tab: Tab = {
+ id: randomUUID(), // 生成新 ID,避免与源窗口冲突
+ name: tabData.name,
+ filePath: tabData.filePath,
+ content: tabData.content,
+ originalContent: tabData.originalContent,
+ isModified: tabData.isModified,
+ scrollRatio: tabData.scrollRatio ?? 0,
+ readOnly: tabData.readOnly,
+ isNewlyLoaded: !tabData.isModified,
+ fileTraits: tabData.fileTraits,
+ };
+
+ tabs.value = [tab];
+ activeTabId.value = tab.id;
+ if (!tabData.isModified) {
+ scheduleNewlyLoadedCleanup(tab.id);
+ }
+
+ // 设置图片路径解析
+ if (tab.filePath) {
+ setCurrentMarkdownFilePath(tab.filePath);
+ }
+
+ // 通知 useContent 同步内容(关键:无此步骤内容会为空)
+ emitter.emit("tab:switch", tab);
+
+ return true;
+ } catch (error) {
+ console.error("[useTab] 初始化 tear-off 数据失败:", error);
+ return false;
+ }
+}
+
+// 立即发起初始化请求(不阻塞模块加载)
+_tearOffInitPromise = initFromTearOff().then(() => {
+ _tearOffInitPromise = null;
+});
+
+// 先同步创建默认 Tab(确保 UI 立即可用),tear-off 初始化成功后会替换它
const defaultTab: Tab = {
id: defaultTabUUid,
name: defaultName,
@@ -230,7 +281,7 @@ async function createTabFromFile(
// 打开文件
async function openFile(filePath: string): Promise {
try {
- // 检查文件是否已经在某个tab中打开
+ // 检查文件是否已经在当前窗口中打开
const existingTab = isFileAlreadyOpen(filePath);
if (existingTab) {
// 如果文件已打开,直接切换到该tab
@@ -238,6 +289,17 @@ async function openFile(filePath: string): Promise {
return existingTab;
}
+ // 检查文件是否在其他窗口中打开
+ try {
+ const result = await window.electronAPI.focusFileIfOpen(filePath);
+ if (result.found) {
+ // 其他窗口已打开该文件并已聚焦,当前窗口无需操作
+ return null;
+ }
+ } catch {
+ // 跨窗口检查失败不影响正常打开
+ }
+
// 使用统一的文件服务读取和处理文件
const fileContent = await readAndProcessFile({ filePath });
if (!fileContent) {
@@ -454,6 +516,267 @@ function reorderTabs(fromIndex: number, toIndex: number) {
tabs.value.splice(toIndex, 0, movedTab);
}
+// ── Tab 拖拽分离 ──────────────────────────────────────────
+
+/** 获取指定 Tab 的完整数据,用于跨窗口传递 */
+function getTabDataForTearOff(tabId: string): TearOffTabData | null {
+ const tab = tabs.value.find((t) => t.id === tabId);
+ if (!tab) return null;
+
+ 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,
+ };
+}
+
+/**
+ * 开始拖拽分离:立即创建新窗口并跟随光标
+ * 由 TabBar 的 pointermove 在指针离开窗口时调用(fire-and-forget)
+ */
+function startTearOff(
+ tabId: string,
+ screenX: number,
+ screenY: number,
+ offsetX: number,
+ offsetY: number
+): void {
+ const tabData = getTabDataForTearOff(tabId);
+ if (!tabData) return;
+ window.electronAPI.tearOffTabStart(tabData, screenX, screenY, offsetX, offsetY);
+}
+
+/**
+ * 取消拖拽分离:指针回到源窗口时调用,关闭已创建的跟随窗口
+ */
+function cancelTearOff(): void {
+ window.electronAPI.tearOffTabCancel();
+}
+
+/**
+ * 完成拖拽分离:停止跟随、判断合并或保留新窗口、从源窗口移除 Tab
+ * 由 TabBar 的 SortableJS onEnd(鼠标松开)时调用
+ */
+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;
+
+ // 成功创建新窗口或合并后,从当前窗口移除该 Tab
+ const isLastTab = tabs.value.length === 1;
+ if (isLastTab) {
+ window.electronAPI.closeDiscard();
+ } else {
+ close(tabId);
+ }
+
+ return true;
+ } catch (error) {
+ console.error("[useTab] Tab 拖拽分离失败:", error);
+ return false;
+ }
+}
+
+// ── Tab 合并接收 ──────────────────────────────────────────
+
+/** 监听来自其他窗口的 Tab 合并请求 */
+function handleTabMergeIn(tabData: TearOffTabData) {
+ const tab: Tab = {
+ id: randomUUID(), // 生成新 ID,避免跨窗口冲突
+ name: tabData.name,
+ filePath: tabData.filePath,
+ content: tabData.content,
+ originalContent: tabData.originalContent,
+ isModified: tabData.isModified,
+ scrollRatio: tabData.scrollRatio ?? 0,
+ readOnly: tabData.readOnly,
+ isNewlyLoaded: !tabData.isModified,
+ fileTraits: tabData.fileTraits,
+ };
+
+ tabs.value.push(tab);
+ activeTabId.value = tab.id;
+ if (!tabData.isModified) {
+ scheduleNewlyLoadedCleanup(tab.id);
+ }
+
+ if (tab.filePath) {
+ setCurrentMarkdownFilePath(tab.filePath);
+ }
+
+ // 通知 useContent 同步内容
+ emitter.emit("tab:switch", tab);
+}
+window.electronAPI.on("tab:merge-in", handleTabMergeIn);
+
+// ── Tab 合并预览(悬停即合并,离开撤销)──────────────────
+
+let mergePreviewState: {
+ tabId: string;
+ prevActiveId: string | null;
+ isExisting: boolean;
+} | null = null;
+
+function handleTabMergePreview(tabData: TearOffTabData, screenX?: number, screenY?: number) {
+ const prevActiveId = activeTabId.value;
+
+ // 若已存在同文件路径的 Tab,直接激活它作为预览目标
+ if (tabData.filePath) {
+ const existing = isFileAlreadyOpen(tabData.filePath);
+ if (existing) {
+ mergePreviewState = {
+ tabId: existing.id,
+ prevActiveId,
+ isExisting: true,
+ };
+ switchToTab(existing.id);
+ return;
+ }
+ }
+
+ // 计算插入位置
+ let insertIndex = tabs.value.length;
+ if (screenX !== undefined && screenY !== undefined) {
+ // 将屏幕坐标转换为页面内坐标
+ // 注意:window.screenX 是窗口左上角在屏幕的 X,加上边框偏移才是内容区
+ // 简化处理:假设标准边框或无边框,contentX ≈ screenX - window.screenX
+ // 更精确的方式难以在纯 IPC 中获取,但如果不考虑标题栏(无框窗口),这样近似可行
+ const clientX = screenX - window.screenX;
+
+ // 获取所有 tab 元素 (排除预览 Tab 自身)
+ const tabElements = Array.from(document.querySelectorAll("[data-tab-id]:not(.merge-preview)"));
+
+ // 找到插入点
+ for (let i = 0; i < tabElements.length; i++) {
+ const rect = tabElements[i].getBoundingClientRect();
+ const centerX = rect.left + rect.width / 2;
+ if (clientX < centerX) {
+ insertIndex = i;
+ break;
+ }
+ }
+ }
+
+ // 取消旧预览,总是重建以确保正确的插入位置
+ if (mergePreviewState && !mergePreviewState.isExisting) {
+ close(mergePreviewState.tabId);
+ }
+
+ const tab: Tab = {
+ id: randomUUID(),
+ name: tabData.name,
+ filePath: tabData.filePath,
+ content: tabData.content,
+ originalContent: tabData.originalContent,
+ isModified: tabData.isModified,
+ scrollRatio: tabData.scrollRatio ?? 0,
+ readOnly: tabData.readOnly,
+ isNewlyLoaded: !tabData.isModified,
+ isMergePreview: true,
+ fileTraits: tabData.fileTraits,
+ };
+
+ // 插入到指定位置
+ tabs.value.splice(insertIndex, 0, tab);
+ activeTabId.value = tab.id;
+ if (!tabData.isModified) {
+ scheduleNewlyLoadedCleanup(tab.id);
+ }
+
+ if (tab.filePath) {
+ setCurrentMarkdownFilePath(tab.filePath);
+ }
+
+ emitter.emit("tab:switch", tab);
+
+ mergePreviewState = {
+ tabId: tab.id,
+ prevActiveId,
+ isExisting: false,
+ };
+}
+
+function handleTabMergePreviewCancel() {
+ if (!mergePreviewState) return;
+ const { tabId, prevActiveId, isExisting } = mergePreviewState;
+ mergePreviewState = null;
+
+ if (!isExisting) {
+ close(tabId);
+ }
+
+ if (prevActiveId && tabs.value.find((tab) => tab.id === prevActiveId)) {
+ switchToTab(prevActiveId);
+ }
+}
+
+function handleTabMergePreviewFinalize() {
+ if (!mergePreviewState) return;
+ const { tabId, isExisting } = mergePreviewState;
+ mergePreviewState = null;
+
+ if (isExisting) return;
+ const tab = tabs.value.find((t) => t.id === tabId);
+ if (tab) {
+ tab.isMergePreview = false;
+ }
+}
+
+window.electronAPI.on("tab:merge-preview", handleTabMergePreview);
+window.electronAPI.on("tab:merge-preview-cancel", handleTabMergePreviewCancel);
+window.electronAPI.on("tab:merge-preview-finalize", handleTabMergePreviewFinalize);
+
+// ── 跨窗口文件去重 ──────────────────────────────────────
+
+/** 主进程通知激活指定文件的 Tab */
+window.electronAPI.on("tab:activate-file", (filePath: string) => {
+ const existingTab = isFileAlreadyOpen(filePath);
+ if (existingTab) {
+ switchToTab(existingTab.id);
+ }
+});
+
+// ── 单 Tab 窗口拖拽 ──────────────────────────────────────
+
+const isSingleTab = computed(() => tabs.value.length === 1);
+
+/**
+ * 开始单 Tab 窗口拖拽:直接移动整个窗口
+ * @param offsetX 鼠标相对窗口左上角的 X 偏移
+ * @param offsetY 鼠标相对窗口左上角的 Y 偏移
+ */
+function startSingleTabDrag(tabId: string, offsetX: number, offsetY: number): void {
+ const tabData = getTabDataForTearOff(tabId);
+ if (!tabData) return;
+ window.electronAPI.startWindowDrag(tabData, offsetX, offsetY);
+}
+
+/**
+ * 结束单 Tab 窗口拖拽:判断是否合并到目标窗口
+ */
+async function endSingleTabDrag(screenX: number, screenY: number): Promise {
+ window.electronAPI.stopWindowDrag();
+
+ // 获取当前唯一 Tab 数据
+ const tab = tabs.value[0];
+ if (!tab) return;
+
+ const tabData = getTabDataForTearOff(tab.id);
+ if (!tabData) return;
+
+ const result = await window.electronAPI.dropMerge(tabData, screenX, screenY);
+ if (result.action === "merged") {
+ // 合并成功,关闭当前窗口
+ window.electronAPI.closeDiscard();
+ }
+}
+
// 设置tab容器的滚动监听
function setupTabScrollListener(containerRef: Ref) {
// 监听激活tab变化,确保其可见
@@ -481,6 +804,7 @@ const formattedTabs = computed(() => {
name: tab.name,
readOnly: tab.readOnly,
isModified: tab.isModified,
+ isMergePreview: tab.isMergePreview,
displayName: tab.isModified ? `*${tab.name}` : tab.name,
}));
});
@@ -606,6 +930,16 @@ function useTab() {
// 拖动
reorderTabs,
+ // Tab 拖拽分离
+ startTearOff,
+ endTearOff,
+ cancelTearOff,
+
+ // 单 Tab 窗口拖拽
+ isSingleTab,
+ startSingleTabDrag,
+ endSingleTabDrag,
+
// 工具
randomUUID,
getFileName,
diff --git a/src/shared/types/tearoff.ts b/src/shared/types/tearoff.ts
new file mode 100644
index 0000000..3fc5a8e
--- /dev/null
+++ b/src/shared/types/tearoff.ts
@@ -0,0 +1,17 @@
+export interface FileTraitsDTO {
+ hasBOM: boolean;
+ lineEnding: "crlf" | "lf";
+ hasTrailingNewline: boolean;
+}
+
+export interface TearOffTabData {
+ id: string;
+ name: string;
+ filePath: string | null;
+ content: string;
+ originalContent: string;
+ isModified: boolean;
+ scrollRatio?: number;
+ readOnly: boolean;
+ fileTraits?: FileTraitsDTO;
+}
diff --git a/src/types/tab.ts b/src/types/tab.ts
index 639cf6c..d80371f 100644
--- a/src/types/tab.ts
+++ b/src/types/tab.ts
@@ -11,5 +11,7 @@ export interface Tab {
codeMirrorCursorOffset?: number | null;
/** 标记 tab 刚加载,编辑器首次输出时捕获为 originalContent */
isNewlyLoaded?: boolean;
+ /** 合并预览中的临时 Tab */
+ isMergePreview?: boolean;
fileTraits?: FileTraitsDTO;
}