feat: 添加标签页拖拽排序功能

- 引入 Sortable.js 库以支持标签页的拖拽排序
- 实现标签页的动态插入和顺序重排
- 更新样式以适应拖拽效果
- 增加 IPC 通信以同步标签页顺序变化
- 优化标签页创建和关闭逻辑,提升用户体验
This commit is contained in:
kuaifan 2026-01-09 15:46:02 +08:00
parent 5a4e51d1e0
commit 9d62ec1ec1
4 changed files with 238 additions and 22 deletions

52
electron/electron.js vendored
View File

@ -41,7 +41,7 @@ const config = require('./package.json');
const electronDown = require("./electron-down"); const electronDown = require("./electron-down");
const electronMenu = require("./electron-menu"); const electronMenu = require("./electron-menu");
const { startMCPServer, stopMCPServer } = require("./lib/mcp"); const { startMCPServer, stopMCPServer } = require("./lib/mcp");
const {onRenderer} = require("./lib/renderer"); const {onRenderer, renderer} = require("./lib/renderer");
const {onExport} = require("./lib/pdf-export"); const {onExport} = require("./lib/pdf-export");
const {allowedCalls, isWin} = require("./lib/other"); const {allowedCalls, isWin} = require("./lib/other");
@ -341,10 +341,10 @@ function createMainWindow() {
// 新窗口处理 // 新窗口处理
mainWindow.webContents.setWindowOpenHandler(({url}) => { mainWindow.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) { if (allowedCalls.test(url)) {
openExternal(url).catch(() => {}) renderer.openExternal(url).catch(() => {})
} else { } else {
utils.onBeforeOpenWindow(mainWindow.webContents, url).then(() => { utils.onBeforeOpenWindow(mainWindow.webContents, url).then(() => {
openExternal(url).catch(() => {}) renderer.openExternal(url).catch(() => {})
}) })
} }
return {action: 'deny'} return {action: 'deny'}
@ -600,10 +600,10 @@ function createChildWindow(args) {
// 新窗口处理 // 新窗口处理
browser.webContents.setWindowOpenHandler(({url}) => { browser.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) { if (allowedCalls.test(url)) {
openExternal(url).catch(() => {}) renderer.openExternal(url).catch(() => {})
} else { } else {
utils.onBeforeOpenWindow(browser.webContents, url).then(() => { utils.onBeforeOpenWindow(browser.webContents, url).then(() => {
openExternal(url).catch(() => {}) renderer.openExternal(url).catch(() => {})
}) })
} }
return {action: 'deny'} return {action: 'deny'}
@ -891,9 +891,9 @@ function createWebTabWindow(args) {
}) })
browserView.webContents.setWindowOpenHandler(({url}) => { browserView.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) { if (allowedCalls.test(url)) {
openExternal(url).catch(() => {}) renderer.openExternal(url).catch(() => {})
} else { } else {
createWebTabWindow({url}) createWebTabWindow({url, afterId: browserView.webContents.id})
} }
return {action: 'deny'} return {action: 'deny'}
}) })
@ -941,9 +941,6 @@ function createWebTabWindow(args) {
}).then(_ => { }) }).then(_ => { })
}) })
browserView.webContents.on('did-start-loading', _ => { browserView.webContents.on('did-start-loading', _ => {
webTabView.forEach(({id: vid, view}) => {
view.setVisible(vid === browserView.webContents.id)
})
if (!webTabWindow) return if (!webTabWindow) return
utils.onDispatchEvent(webTabWindow.webContents, { utils.onDispatchEvent(webTabWindow.webContents, {
event: 'start-loading', event: 'start-loading',
@ -981,10 +978,22 @@ function createWebTabWindow(args) {
electronMenu.webContentsMenu(browserView.webContents, true) electronMenu.webContentsMenu(browserView.webContents, true)
browserView.webContents.loadURL(args.url).then(_ => { }).catch(_ => { }) browserView.webContents.loadURL(args.url).then(_ => { }).catch(_ => { })
browserView.setVisible(true) browserView.setVisible(true)
webTabWindow.contentView.addChildView(browserView) webTabWindow.contentView.addChildView(browserView)
webTabView.push({
// 确定插入位置
let insertIndex = webTabView.length
if (args.afterId) {
const afterIndex = webTabView.findIndex(item => item.id === args.afterId)
if (afterIndex > -1) {
insertIndex = afterIndex + 1
}
}
// 插入到指定位置
webTabView.splice(insertIndex, 0, {
id: browserView.webContents.id, id: browserView.webContents.id,
view: browserView view: browserView
}) })
@ -993,6 +1002,7 @@ function createWebTabWindow(args) {
event: 'create', event: 'create',
id: browserView.webContents.id, id: browserView.webContents.id,
url: args.url, url: args.url,
afterId: args.afterId,
}).then(_ => { }) }).then(_ => { })
activateWebTab(browserView.webContents.id) activateWebTab(browserView.webContents.id)
} }
@ -1350,6 +1360,24 @@ ipcMain.on('webTabActivate', (event, id) => {
event.returnValue = "ok" event.returnValue = "ok"
}) })
/**
* 内置浏览器 - 重排标签顺序
* @param newOrder 新的标签ID顺序数组
*/
ipcMain.on('webTabReorder', (event, newOrder) => {
if (!Array.isArray(newOrder) || newOrder.length === 0) {
event.returnValue = "ok"
return
}
// 根据新顺序重排 webTabView 数组
webTabView.sort((a, b) => {
const indexA = newOrder.indexOf(a.id)
const indexB = newOrder.indexOf(b.id)
return indexA - indexB
})
event.returnValue = "ok"
})
/** /**
* 内置浏览器 - 关闭标签 * 内置浏览器 - 关闭标签
* @param id * @param id
@ -1367,7 +1395,7 @@ ipcMain.on('webTabExternal', (event) => {
if (!item) { if (!item) {
return return
} }
openExternal(item.view.webContents.getURL()).catch(() => {}) renderer.openExternal(item.view.webContents.getURL()).catch(() => {})
event.returnValue = "ok" event.returnValue = "ok"
}) })

View File

@ -34,9 +34,11 @@ body {
font-feature-settings: 'clig', 'kern'; font-feature-settings: 'clig', 'kern';
flex: 1; flex: 1;
width: 0; width: 0;
height: 40px;
display: flex; display: flex;
cursor: default; align-items: center;
background-color: var(--tab-background); background-color: var(--tab-background);
cursor: default;
-webkit-app-region: drag; -webkit-app-region: drag;
} }
@ -77,8 +79,6 @@ body {
flex: 1; flex: 1;
display: flex; display: flex;
gap: 8px; gap: 8px;
height: 35px;
margin-top: 5px;
user-select: none; user-select: none;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
@ -89,15 +89,17 @@ body {
} }
.nav-tabs li { .nav-tabs li {
display: inline-flex; flex: 1;
display: flex;
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
align-items: center; align-items: center;
height: calc(100% - 5px); height: calc(100% - 5px);
padding: 7px 8px; padding: 7px 8px;
min-width: 100px; min-width: 56px;
max-width: 240px; max-width: 220px;
scroll-margin: 12px; scroll-margin: 12px;
border-radius: 4px;
font-size: var(--tab-font-size); font-size: var(--tab-font-size);
color: var(--tab-color); color: var(--tab-color);
cursor: var(--tab-cursor); cursor: var(--tab-cursor);
@ -113,7 +115,6 @@ body {
.nav-tabs li.active { .nav-tabs li.active {
color: var(--tab-active-color); color: var(--tab-active-color);
background: var(--tab-active-background); background: var(--tab-active-background);
border-radius: 4px;
} }
.nav-tabs li.active .tab-icon.background { .nav-tabs li.active .tab-icon.background {
@ -254,6 +255,89 @@ body.darwin.full-screen .nav {
padding-left: 8px; padding-left: 8px;
} }
/* Sortable 拖拽样式 */
.nav-tabs li.sortable-ghost {
opacity: 0.4;
background: var(--tab-active-background);
border-radius: 4px;
}
.nav-tabs li.sortable-chosen {
background: var(--tab-active-background);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.nav-tabs li.sortable-drag {
opacity: 1;
}
/* fallback 模式下克隆元素会被添加到 body需要全局样式 */
.sortable-fallback {
font-family: var(--tab-font-family);
font-size: var(--tab-font-size);
display: inline-flex !important;
align-items: center;
height: 30px;
padding: 7px 8px;
min-width: 100px;
max-width: 240px;
color: var(--tab-active-color);
background: var(--tab-active-background) !important;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
opacity: 0.95 !important;
cursor: grabbing;
z-index: 9999;
}
.sortable-fallback .tab-icon {
display: inline-block;
flex-shrink: 0;
width: 16px;
height: 16px;
background-size: cover;
}
.sortable-fallback .tab-icon.background {
background-image: url(../image/link_normal_selected_icon.png);
}
.sortable-fallback .tab-title {
display: inline-block;
flex: 1;
margin: 0 8px;
overflow: hidden;
line-height: 150%;
text-overflow: ellipsis;
white-space: nowrap;
}
.sortable-fallback .tab-close {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 2px;
position: relative;
}
.sortable-fallback .tab-close::after,
.sortable-fallback .tab-close::before {
position: absolute;
top: 50%;
right: 50%;
transform: translate(50%, -50%) scale(0.9) rotate(45deg);
content: "";
width: 2px;
height: 11px;
border-radius: 3px;
background-color: var(--tab-close-color);
}
.sortable-fallback .tab-close::before {
transform: translate(50%, -50%) scale(0.9) rotate(-45deg);
}
/* 暗黑模式 */ /* 暗黑模式 */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@ -275,4 +359,13 @@ body.darwin.full-screen .nav {
.tab-icon.background { .tab-icon.background {
background-image: url(../image/dark/link_normal_icon.png); background-image: url(../image/dark/link_normal_icon.png);
} }
/* 暗黑模式下 fallback 样式 */
.sortable-fallback {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.sortable-fallback .tab-icon.background {
background-image: url(../image/dark/link_normal_selected_icon.png);
}
} }

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,7 @@
<title>Untitled</title> <title>Untitled</title>
<link rel="stylesheet" href="./assets/css/style.css"> <link rel="stylesheet" href="./assets/css/style.css">
<script src="./assets/js/vue.global.min.js"></script> <script src="./assets/js/vue.global.min.js"></script>
<script src="./assets/js/Sortable.min.js"></script>
</head> </head>
<body> <body>
<div id="app" class="app"> <div id="app" class="app">
@ -22,7 +23,7 @@
</div> </div>
</div> </div>
<ul class="nav-tabs"> <ul class="nav-tabs">
<li v-for="item in tabs" :data-id="item.id" :class="{active: activeId === item.id}" @click="onSwitch(item)"> <li v-for="item in tabs" :key="item.id" :data-id="item.id" :class="{active: activeId === item.id}" @click="onSwitch(item)">
<div v-if="item.state === 'loading'" class="tab-icon loading"> <div v-if="item.state === 'loading'" class="tab-icon loading">
<div class="tab-icon-loading"></div> <div class="tab-icon-loading"></div>
</div> </div>
@ -55,24 +56,42 @@
// 是否可以前进 // 是否可以前进
canGoForward: false, canGoForward: false,
// 是否正在拖拽(用于跳过 watch 同步)
isDragging: false,
} }
}, },
beforeCreate() { beforeCreate() {
document.body.classList.add(window.process.platform) document.body.classList.add(window.process.platform)
}, },
mounted() { mounted() {
// 初始化 Sortable 拖拽排序
this.initSortable()
window.__onDispatchEvent = (detail) => { window.__onDispatchEvent = (detail) => {
const {id, event} = detail const {id, event} = detail
switch (event) { switch (event) {
// 创建标签页 // 创建标签页
case 'create': case 'create':
this.tabs.push(Object.assign({ const newTab = Object.assign({
id, id,
title: '', title: '',
url: '', url: '',
icon: '', icon: '',
state: 'loading' state: 'loading'
}, detail)) }, detail)
// 确定插入位置
let insertIndex = this.tabs.length
if (detail.afterId) {
const afterIndex = this.tabs.findIndex(item => item.id === detail.afterId)
if (afterIndex > -1) {
insertIndex = afterIndex + 1
}
}
// 插入到指定位置
this.tabs.splice(insertIndex, 0, newTab)
break break
// 关闭标签页 // 关闭标签页
@ -202,8 +221,82 @@
pageTitle(title) { pageTitle(title) {
document.title = title; document.title = title;
}, },
/**
* 监听标签列表变化,同步 Sortable 顺序
*/
tabs: {
handler() {
// 拖拽过程中跳过同步,由 onEnd 处理
if (this.isDragging) return
this.$nextTick(() => {
if (this.sortable) {
// 根据 Vue 数据顺序同步 Sortable 的 DOM 顺序
const order = this.tabs.map(t => String(t.id))
this.sortable.sort(order, true)
}
})
},
deep: false
}
}, },
methods: { methods: {
/**
* 初始化 Sortable 拖拽排序
*/
initSortable() {
const el = document.querySelector('.nav-tabs')
if (!el) return
this.sortable = new Sortable(el, {
animation: 150,
delay: 50,
delayOnTouchOnly: true,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
// 使用 fallback 模式,在 Electron 中更稳定
forceFallback: true,
fallbackClass: 'sortable-fallback',
fallbackOnBody: true,
fallbackTolerance: 3,
// 拖拽开始时激活该标签
onStart: (evt) => {
this.isDragging = true
const item = this.tabs[evt.oldIndex]
if (item && this.activeId !== item.id) {
this.sendMessage('webTabActivate', item.id)
}
},
// 拖拽结束回调
onEnd: (evt) => {
const { oldIndex, newIndex } = evt
if (oldIndex !== newIndex) {
// 先将 DOM 恢复到原始位置,让 Vue 来控制渲染
const parent = evt.from
const children = Array.from(parent.children)
if (oldIndex < newIndex) {
parent.insertBefore(evt.item, children[oldIndex])
} else {
parent.insertBefore(evt.item, children[oldIndex + 1] || null)
}
// 更新 Vue 数据(触发响应式更新)
const item = this.tabs.splice(oldIndex, 1)[0]
this.tabs.splice(newIndex, 0, item)
// 通知主进程同步顺序
this.sendMessage('webTabReorder', this.tabs.map(t => t.id))
}
// 延迟重置标志,确保 Vue 渲染完成
this.$nextTick(() => {
this.isDragging = false
})
}
})
},
/** /**
* 切换标签页 * 切换标签页
* @param item * @param item