diff --git a/electron/electron.js b/electron/electron.js index ff8ace348..2e861cfcc 100644 --- a/electron/electron.js +++ b/electron/electron.js @@ -85,6 +85,9 @@ let mediaType = null, webTabHeight = 40, webTabClosedByShortcut = false; +// 多窗口 Tab 管理器 +let webTabWindows = new Map(); // windowId -> { id, window, tabs: [{id, view, url, title, icon}], activeTabId } + // 开发模式路径 let devloadPath = path.resolve(__dirname, ".devload"); @@ -735,6 +738,412 @@ function createMediaWindow(args, type = 'image') { }); } +/** + * 从 webContents 获取所属的 webTabWindow ID + * @param {WebContents} sender - 发送者的 webContents + * @returns {number|null} 窗口 ID 或 null + */ +function getWebTabWindowId(sender) { + const win = BrowserWindow.fromWebContents(sender) + if (win && webTabWindows.has(win.id)) { + return win.id + } + for (const [id, data] of webTabWindows) { + if (data.window.webContents === sender) { + return id + } + } + return null +} + +/** + * 获取 webTabWindow 数据 + * @param {number} windowId - 窗口 ID + * @returns {object|null} 窗口数据或 null + */ +function getWebTabWindowData(windowId) { + return webTabWindows.get(windowId) || null +} + +/** + * 在指定窗口中查找 Tab + * @param {number} windowId - 窗口 ID + * @param {number} tabId - Tab ID + * @returns {object|null} Tab 数据或 null + */ +function findTabInWindow(windowId, tabId) { + const windowData = webTabWindows.get(windowId) + if (!windowData) return null + return windowData.tabs.find(t => t.id === tabId) || null +} + +/** + * 在指定位置创建新的 Tab 窗口(用于 Tab 分离) + * @param {object} position - {x, y} 屏幕坐标 + * @param {object} tabData - 初始 Tab 数据 + * @returns {object} 新窗口数据 + */ +function createWebTabWindowAt(position, tabData) { + const titleBarOverlay = { + height: webTabHeight + } + if (nativeTheme.shouldUseDarkColors) { + titleBarOverlay.color = '#3B3B3D' + titleBarOverlay.symbolColor = '#C5C5C5' + } + + const newWindow = new BrowserWindow({ + x: Math.max(0, position.x - 640), + y: Math.max(0, position.y - 30), + width: 1280, + height: 800, + minWidth: 360, + minHeight: 360, + show: true, + autoHideMenuBar: true, + titleBarStyle: 'hidden', + titleBarOverlay, + backgroundColor: nativeTheme.shouldUseDarkColors ? '#575757' : '#FFFFFF', + webPreferences: { + preload: path.join(__dirname, 'electron-preload.js'), + webSecurity: true, + nodeIntegration: true, + contextIsolation: true, + } + }) + + const windowId = newWindow.id + + // 复用窗口关闭快捷键处理 + const originalClose = newWindow.close + newWindow.close = function() { + webTabClosedByShortcut = true + return originalClose.apply(this, arguments) + } + + newWindow.on('resize', () => { + resizeWebTabInWindow(windowId, 0) + }) + + newWindow.on('enter-full-screen', () => { + utils.onDispatchEvent(newWindow.webContents, { + event: 'enter-full-screen', + }).then(_ => { }) + }) + + newWindow.on('leave-full-screen', () => { + utils.onDispatchEvent(newWindow.webContents, { + event: 'leave-full-screen', + }).then(_ => { }) + }) + + newWindow.on('close', event => { + if (webTabClosedByShortcut) { + webTabClosedByShortcut = false + if (!willQuitApp) { + closeWebTabInWindow(windowId, 0) + event.preventDefault() + return + } + } + userConf.set(`webTabWindow_${windowId}`, newWindow.getBounds()) + }) + + newWindow.on('closed', () => { + const windowData = webTabWindows.get(windowId) + if (windowData) { + windowData.tabs.forEach(({view}) => { + try { + view.webContents.close() + } catch (e) {} + }) + } + webTabWindows.delete(windowId) + + // 同步更新旧的 webTabView 数组 + if (windowData) { + windowData.tabs.forEach(tab => { + const idx = webTabView.findIndex(t => t.id === tab.id) + if (idx > -1) webTabView.splice(idx, 1) + }) + } + + // 如果关闭的是当前活跃窗口,切换到其他窗口 + if (webTabWindow === newWindow) { + webTabWindow = null + for (const [, data] of webTabWindows) { + webTabWindow = data.window + break + } + } + }) + + newWindow.once('ready-to-show', () => { + onShowWindow(newWindow) + }) + + newWindow.webContents.once('dom-ready', () => { + onShowWindow(newWindow) + }) + + newWindow.webContents.on('before-input-event', (event, input) => { + if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'r') { + reloadWebTabInWindow(windowId, 0) + event.preventDefault() + } else if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'w') { + webTabClosedByShortcut = true + } else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') { + devToolsWebTabInWindow(windowId, 0) + } else { + const windowData = webTabWindows.get(windowId) + if (windowData && windowData.activeTabId) { + const tab = windowData.tabs.find(t => t.id === windowData.activeTabId) + if (tab) { + navigation.handleInput(event, input, tab.view.webContents) + } + } + } + }) + + navigation.setupWindowEvents(newWindow, () => { + const windowData = webTabWindows.get(windowId) + if (windowData && windowData.activeTabId) { + const tab = windowData.tabs.find(t => t.id === windowData.activeTabId) + return tab ? tab.view.webContents : null + } + return null + }) + + newWindow.loadFile('./render/tabs/index.html') + + // 注册到多窗口管理器 + const windowData = { + id: windowId, + window: newWindow, + tabs: [], + activeTabId: null + } + webTabWindows.set(windowId, windowData) + + return windowData +} + +/** + * 将 Tab 从源窗口转移到目标窗口 + * @param {number} tabId - Tab 的 webContents.id + * @param {number} fromWindowId - 源窗口 ID + * @param {number|null} toWindowId - 目标窗口 ID(null 表示创建新窗口) + * @param {object} position - 新窗口位置 {x, y}(屏幕坐标) + * @returns {boolean} 是否成功 + */ +function transferTab(tabId, fromWindowId, toWindowId, position) { + const sourceData = webTabWindows.get(fromWindowId) + if (!sourceData) return false + + const tabIndex = sourceData.tabs.findIndex(t => t.id === tabId) + if (tabIndex === -1) return false + + const tabData = sourceData.tabs[tabIndex] + const view = tabData.view + + // 1. 从源窗口移除 view + sourceData.window.contentView.removeChildView(view) + sourceData.tabs.splice(tabIndex, 1) + + // 从旧的 webTabView 数组中移除 + const oldIdx = webTabView.findIndex(t => t.id === tabId) + if (oldIdx > -1) webTabView.splice(oldIdx, 1) + + // 2. 通知源窗口前端更新 + utils.onDispatchEvent(sourceData.window.webContents, { + event: 'tab-removed', + id: tabId + }).then(_ => { }) + + // 3. 处理源窗口空 Tab 情况 + if (sourceData.tabs.length === 0) { + userConf.set(`webTabWindow_${fromWindowId}`, sourceData.window.getBounds()) + sourceData.window.destroy() + // webTabWindows.delete 会在 closed 事件中处理 + } else { + // 激活源窗口的下一个 Tab + const nextTab = sourceData.tabs[Math.min(tabIndex, sourceData.tabs.length - 1)] + activateWebTabInWindow(fromWindowId, nextTab.id) + } + + // 4. 获取或创建目标窗口 + let targetData + if (toWindowId && webTabWindows.has(toWindowId)) { + targetData = webTabWindows.get(toWindowId) + } else { + targetData = createWebTabWindowAt(position, tabData) + } + + // 等待目标窗口 TabBar 加载完成后再添加 Tab + const addTabToTarget = () => { + // 5. 将 view 添加到目标窗口 + targetData.window.contentView.addChildView(view) + targetData.tabs.push(tabData) + + // 同步添加到旧的 webTabView 数组 + webTabView.push(tabData) + + // 6. 调整 view 尺寸适应新窗口 + view.setBounds({ + x: 0, + y: webTabHeight, + width: targetData.window.getContentBounds().width || 1280, + height: (targetData.window.getContentBounds().height || 800) - webTabHeight + }) + view.setVisible(true) + + // 7. 通知目标窗口前端更新 + utils.onDispatchEvent(targetData.window.webContents, { + event: 'tab-added', + id: tabId, + title: tabData.title || '', + url: tabData.url || '', + icon: tabData.icon || '' + }).then(_ => { }) + + // 8. 激活新添加的 Tab + activateWebTabInWindow(targetData.id, tabId) + + // 9. 聚焦目标窗口 + targetData.window.focus() + } + + // 如果是新创建的窗口,等待 dom-ready 后再添加 Tab + if (!toWindowId) { + targetData.window.webContents.once('dom-ready', () => { + setTimeout(addTabToTarget, 100) + }) + } else { + addTabToTarget() + } + + return true +} + +/** + * 在指定窗口中调整 Tab 尺寸 + */ +function resizeWebTabInWindow(windowId, tabId) { + const windowData = webTabWindows.get(windowId) + if (!windowData) return + + const item = tabId === 0 + ? windowData.tabs.find(t => t.id === windowData.activeTabId) + : windowData.tabs.find(t => t.id === tabId) + if (!item) return + + item.view.setBounds({ + x: 0, + y: webTabHeight, + width: windowData.window.getContentBounds().width || 1280, + height: (windowData.window.getContentBounds().height || 800) - webTabHeight + }) +} + +/** + * 在指定窗口中激活 Tab + */ +function activateWebTabInWindow(windowId, tabId) { + const windowData = webTabWindows.get(windowId) + if (!windowData) return + + const item = tabId === 0 + ? windowData.tabs[0] + : windowData.tabs.find(t => t.id === tabId) + if (!item) return + + windowData.tabs.forEach(({id, view}) => { + view.setVisible(id === item.id) + }) + + resizeWebTabInWindow(windowId, item.id) + item.view.webContents.focus() + windowData.activeTabId = item.id + + utils.onDispatchEvent(windowData.window.webContents, { + event: 'switch', + id: item.id + }).then(_ => { }) +} + +/** + * 在指定窗口中关闭 Tab + */ +function closeWebTabInWindow(windowId, tabId) { + const windowData = webTabWindows.get(windowId) + if (!windowData) return + + const item = tabId === 0 + ? windowData.tabs.find(t => t.id === windowData.activeTabId) + : windowData.tabs.find(t => t.id === tabId) + if (!item) return + + if (windowData.tabs.length === 1) { + windowData.window.hide() + } + + windowData.window.contentView.removeChildView(item.view) + try { + item.view.webContents.close() + } catch (e) {} + + const index = windowData.tabs.findIndex(t => t.id === item.id) + if (index > -1) { + windowData.tabs.splice(index, 1) + } + + // 从旧数组中移除 + const oldIdx = webTabView.findIndex(t => t.id === item.id) + if (oldIdx > -1) webTabView.splice(oldIdx, 1) + + utils.onDispatchEvent(windowData.window.webContents, { + event: 'close', + id: item.id + }).then(_ => { }) + + if (windowData.tabs.length === 0) { + userConf.set(`webTabWindow_${windowId}`, windowData.window.getBounds()) + windowData.window.destroy() + } else { + activateWebTabInWindow(windowId, 0) + } +} + +/** + * 在指定窗口中刷新 Tab + */ +function reloadWebTabInWindow(windowId, tabId) { + const windowData = webTabWindows.get(windowId) + if (!windowData) return + + const item = tabId === 0 + ? windowData.tabs.find(t => t.id === windowData.activeTabId) + : windowData.tabs.find(t => t.id === tabId) + if (!item) return + + item.view.webContents.reload() +} + +/** + * 在指定窗口中打开 DevTools + */ +function devToolsWebTabInWindow(windowId, tabId) { + const windowData = webTabWindows.get(windowId) + if (!windowData) return + + const item = tabId === 0 + ? windowData.tabs.find(t => t.id === windowData.activeTabId) + : windowData.tabs.find(t => t.id === tabId) + if (!item) return + + item.view.webContents.openDevTools() +} + /** * 创建内置浏览器 * @param args {url, ?} @@ -820,6 +1229,8 @@ function createWebTabWindow(args) { // } }) + // 从多窗口管理器中删除 + webTabWindows.delete(webTabWindow.id) webTabView = [] webTabWindow = null }) @@ -855,6 +1266,14 @@ function createWebTabWindow(args) { }) webTabWindow.loadFile('./render/tabs/index.html', {}).then(_ => { }).catch(_ => { }) + + // 注册到多窗口管理器 + webTabWindows.set(webTabWindow.id, { + id: webTabWindow.id, + window: webTabWindow, + tabs: [], + activeTabId: null + }) } if (webTabWindow.isMinimized()) { webTabWindow.restore() @@ -984,10 +1403,24 @@ function createWebTabWindow(args) { browserView.setVisible(true) webTabWindow.contentView.addChildView(browserView) - webTabView.push({ + + // 创建 Tab 数据对象 + const tabData = { id: browserView.webContents.id, - view: browserView - }) + view: browserView, + url: args.url, + title: '', + icon: '' + } + + // 添加到原有数组(兼容) + webTabView.push(tabData) + + // 添加到多窗口管理器 + const windowData = webTabWindows.get(webTabWindow.id) + if (windowData) { + windowData.tabs.push(tabData) + } utils.onDispatchEvent(webTabWindow.webContents, { event: 'create', @@ -1086,6 +1519,13 @@ function activateWebTab(id) { }) resizeWebTab(item.id) item.view.webContents.focus() + + // 更新多窗口管理器的 activeTabId + const windowData = webTabWindows.get(webTabWindow.id) + if (windowData) { + windowData.activeTabId = item.id + } + utils.onDispatchEvent(webTabWindow.webContents, { event: 'switch', id: item.id, @@ -1111,11 +1551,21 @@ function closeWebTab(id) { // } + // 从原有数组中删除 const index = webTabView.findIndex(({id}) => item.id == id) if (index > -1) { webTabView.splice(index, 1) } + // 从多窗口管理器中删除 + const windowData = webTabWindows.get(webTabWindow.id) + if (windowData) { + const tabIndex = windowData.tabs.findIndex(t => t.id === item.id) + if (tabIndex > -1) { + windowData.tabs.splice(tabIndex, 1) + } + } + utils.onDispatchEvent(webTabWindow.webContents, { event: 'close', id: item.id, @@ -1359,6 +1809,35 @@ ipcMain.on('webTabClose', (event, id) => { event.returnValue = "ok" }) +/** + * 内置浏览器 - Tab 排序变更 + * @param tabIds - 排序后的 Tab ID 数组 + */ +ipcMain.on('webTabReorder', (event, { tabIds }) => { + const windowId = getWebTabWindowId(event.sender) + if (windowId) { + const windowData = webTabWindows.get(windowId) + if (windowData && tabIds) { + windowData.tabs.sort((a, b) => tabIds.indexOf(a.id) - tabIds.indexOf(b.id)) + } + } + event.returnValue = "ok" +}) + +/** + * 内置浏览器 - Tab 分离到新窗口 + * @param tabId - 要分离的 Tab ID + * @param screenX - 屏幕 X 坐标 + * @param screenY - 屏幕 Y 坐标 + */ +ipcMain.on('webTabDetach', (event, { tabId, screenX, screenY }) => { + const windowId = getWebTabWindowId(event.sender) + if (windowId && tabId) { + transferTab(tabId, windowId, null, { x: screenX, y: screenY }) + } + event.returnValue = "ok" +}) + /** * 内置浏览器 - 在外部浏览器打开 */ diff --git a/electron/render/tabs/assets/css/style.css b/electron/render/tabs/assets/css/style.css index d93bc9b74..4f2e22e74 100644 --- a/electron/render/tabs/assets/css/style.css +++ b/electron/render/tabs/assets/css/style.css @@ -276,3 +276,26 @@ body.darwin.full-screen .nav { background-image: url(../image/dark/link_normal_icon.png); } } + +/* 拖拽排序样式 */ +body.dragging .nav-tabs li { + cursor: grabbing; +} + +.nav-tabs li.sortable-ghost { + opacity: 0.4; + background: var(--tab-active-background); + border-radius: 4px; +} + +.nav-tabs li.sortable-drag { + opacity: 1; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + border-radius: 4px; + background: var(--tab-active-background); +} + +.nav-tabs li.sortable-chosen { + background: var(--tab-active-background); + border-radius: 4px; +} diff --git a/electron/render/tabs/assets/js/Sortable.min.js b/electron/render/tabs/assets/js/Sortable.min.js new file mode 100644 index 000000000..95423a649 --- /dev/null +++ b/electron/render/tabs/assets/js/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientYUntitled +
@@ -49,12 +50,18 @@ // 停止定时器 stopTimer: null, - + // 是否可以后退 canGoBack: false, // 是否可以前进 canGoForward: false, + + // 拖拽相关状态 + isDragging: false, + dragTabId: null, + sortableInstance: null, + dragThreshold: 50, } }, beforeCreate() { @@ -155,11 +162,43 @@ case 'leave-full-screen': document.body.classList.remove('full-screen') break + + // Tab 被移除(转移到其他窗口) + case 'tab-removed': + const removeIndex = this.tabs.findIndex(item => item.id === id) + if (removeIndex > -1) { + this.tabs.splice(removeIndex, 1) + // 如果移除的是当前激活的 Tab,切换到第一个 + if (this.activeId === id && this.tabs.length > 0) { + this.activeId = this.tabs[0].id + } + } + break + + // Tab 被添加(从其他窗口转移来) + case 'tab-added': + this.tabs.push({ + id: detail.id, + title: detail.title || '', + url: detail.url || '', + icon: detail.icon || '', + state: 'loaded' + }) + this.activeId = detail.id + this.$nextTick(() => { + this.scrollTabActive() + }) + break } } window.__openDevTools = () => { this.sendMessage('webTabOpenDevTools') } + + // 初始化拖拽排序 + this.$nextTick(() => { + this.initSortable() + }) }, computed: { /** @@ -333,6 +372,87 @@ } catch (e) { return false } + }, + + /** + * 初始化拖拽排序 + */ + initSortable() { + const tabList = document.querySelector('.nav-tabs') + if (!tabList || this.sortableInstance) return + + this.sortableInstance = new Sortable(tabList, { + animation: 150, + delay: 100, + delayOnTouchOnly: true, + direction: 'horizontal', + ghostClass: 'sortable-ghost', + dragClass: 'sortable-drag', + filter: '.tab-close', + + onStart: (evt) => { + this.isDragging = true + this.dragTabId = this.tabs[evt.oldIndex]?.id + document.body.classList.add('dragging') + }, + + onMove: (evt, originalEvent) => { + // 检测是否拖出窗口边界 + const y = originalEvent.clientY + if (y < -this.dragThreshold || y > window.innerHeight + this.dragThreshold) { + this.detachTab(originalEvent.screenX, originalEvent.screenY) + return false + } + return true + }, + + onEnd: (evt) => { + document.body.classList.remove('dragging') + + if (!this.isDragging) return + this.isDragging = false + + // 如果是正常排序结束(位置有变化) + if (evt.oldIndex !== evt.newIndex) { + const [item] = this.tabs.splice(evt.oldIndex, 1) + this.tabs.splice(evt.newIndex, 0, item) + this.sendMessage('webTabReorder', { + tabIds: this.tabs.map(t => t.id) + }) + } + + this.dragTabId = null + } + }) + }, + + /** + * 分离 Tab 到新窗口 + * @param screenX + * @param screenY + */ + detachTab(screenX, screenY) { + if (!this.dragTabId) return + + // 发送分离请求到主进程 + this.sendMessage('webTabDetach', { + tabId: this.dragTabId, + screenX, + screenY + }) + + // 重置拖拽状态 + this.isDragging = false + this.dragTabId = null + document.body.classList.remove('dragging') + + // 临时禁用 Sortable 避免状态冲突 + if (this.sortableInstance) { + this.sortableInstance.option('disabled', true) + setTimeout(() => { + this.sortableInstance.option('disabled', false) + }, 100) + } } }, }