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; }