feat: 支持 Tab 拖拽排序和拖出窗口创建独立窗口

实现类似 Chrome 的 Tab 管理功能:
  - 添加多窗口 Tab 管理器 (webTabWindows Map)
  - 支持 Tab 拖拽排序(使用 SortableJS)
  - 支持将 Tab 拖出窗口边界创建独立窗口
  - 添加 Tab 转移函数 transferTab 实现跨窗口 Tab 迁移
  - 前端添加拖拽检测和分离逻辑
  - 添加拖拽排序相关 CSS 样式
This commit is contained in:
kuaifan 2026-01-08 15:16:42 +00:00
parent 5a4e51d1e0
commit fbbaff55f3
4 changed files with 628 additions and 4 deletions

485
electron/electron.js vendored
View File

@ -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 - 目标窗口 IDnull 表示创建新窗口
* @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"
})
/**
* 内置浏览器 - 在外部浏览器打开
*/

View File

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

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,7 @@
<title>Untitled</title>
<link rel="stylesheet" href="./assets/css/style.css">
<script src="./assets/js/vue.global.min.js"></script>
<script src="./assets/js/Sortable.min.js"></script>
</head>
<body>
<div id="app" class="app">
@ -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)
}
}
},
}