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 electronMenu = require("./electron-menu");
const { startMCPServer, stopMCPServer } = require("./lib/mcp");
const {onRenderer} = require("./lib/renderer");
const {onRenderer, renderer} = require("./lib/renderer");
const {onExport} = require("./lib/pdf-export");
const {allowedCalls, isWin} = require("./lib/other");
@ -341,10 +341,10 @@ function createMainWindow() {
// 新窗口处理
mainWindow.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
openExternal(url).catch(() => {})
renderer.openExternal(url).catch(() => {})
} else {
utils.onBeforeOpenWindow(mainWindow.webContents, url).then(() => {
openExternal(url).catch(() => {})
renderer.openExternal(url).catch(() => {})
})
}
return {action: 'deny'}
@ -600,10 +600,10 @@ function createChildWindow(args) {
// 新窗口处理
browser.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
openExternal(url).catch(() => {})
renderer.openExternal(url).catch(() => {})
} else {
utils.onBeforeOpenWindow(browser.webContents, url).then(() => {
openExternal(url).catch(() => {})
renderer.openExternal(url).catch(() => {})
})
}
return {action: 'deny'}
@ -891,9 +891,9 @@ function createWebTabWindow(args) {
})
browserView.webContents.setWindowOpenHandler(({url}) => {
if (allowedCalls.test(url)) {
openExternal(url).catch(() => {})
renderer.openExternal(url).catch(() => {})
} else {
createWebTabWindow({url})
createWebTabWindow({url, afterId: browserView.webContents.id})
}
return {action: 'deny'}
})
@ -941,9 +941,6 @@ function createWebTabWindow(args) {
}).then(_ => { })
})
browserView.webContents.on('did-start-loading', _ => {
webTabView.forEach(({id: vid, view}) => {
view.setVisible(vid === browserView.webContents.id)
})
if (!webTabWindow) return
utils.onDispatchEvent(webTabWindow.webContents, {
event: 'start-loading',
@ -981,10 +978,22 @@ function createWebTabWindow(args) {
electronMenu.webContentsMenu(browserView.webContents, true)
browserView.webContents.loadURL(args.url).then(_ => { }).catch(_ => { })
browserView.setVisible(true)
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,
view: browserView
})
@ -993,6 +1002,7 @@ function createWebTabWindow(args) {
event: 'create',
id: browserView.webContents.id,
url: args.url,
afterId: args.afterId,
}).then(_ => { })
activateWebTab(browserView.webContents.id)
}
@ -1350,6 +1360,24 @@ ipcMain.on('webTabActivate', (event, id) => {
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
@ -1367,7 +1395,7 @@ ipcMain.on('webTabExternal', (event) => {
if (!item) {
return
}
openExternal(item.view.webContents.getURL()).catch(() => {})
renderer.openExternal(item.view.webContents.getURL()).catch(() => {})
event.returnValue = "ok"
})

View File

@ -34,9 +34,11 @@ body {
font-feature-settings: 'clig', 'kern';
flex: 1;
width: 0;
height: 40px;
display: flex;
cursor: default;
align-items: center;
background-color: var(--tab-background);
cursor: default;
-webkit-app-region: drag;
}
@ -77,8 +79,6 @@ body {
flex: 1;
display: flex;
gap: 8px;
height: 35px;
margin-top: 5px;
user-select: none;
overflow-x: auto;
overflow-y: hidden;
@ -89,15 +89,17 @@ body {
}
.nav-tabs li {
display: inline-flex;
flex: 1;
display: flex;
position: relative;
box-sizing: border-box;
align-items: center;
height: calc(100% - 5px);
padding: 7px 8px;
min-width: 100px;
max-width: 240px;
min-width: 56px;
max-width: 220px;
scroll-margin: 12px;
border-radius: 4px;
font-size: var(--tab-font-size);
color: var(--tab-color);
cursor: var(--tab-cursor);
@ -113,7 +115,6 @@ body {
.nav-tabs li.active {
color: var(--tab-active-color);
background: var(--tab-active-background);
border-radius: 4px;
}
.nav-tabs li.active .tab-icon.background {
@ -254,6 +255,89 @@ body.darwin.full-screen .nav {
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) {
:root {
@ -275,4 +359,13 @@ body.darwin.full-screen .nav {
.tab-icon.background {
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>
<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">
@ -22,7 +23,7 @@
</div>
</div>
<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 class="tab-icon-loading"></div>
</div>
@ -55,24 +56,42 @@
// 是否可以前进
canGoForward: false,
// 是否正在拖拽(用于跳过 watch 同步)
isDragging: false,
}
},
beforeCreate() {
document.body.classList.add(window.process.platform)
},
mounted() {
// 初始化 Sortable 拖拽排序
this.initSortable()
window.__onDispatchEvent = (detail) => {
const {id, event} = detail
switch (event) {
// 创建标签页
case 'create':
this.tabs.push(Object.assign({
const newTab = Object.assign({
id,
title: '',
url: '',
icon: '',
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
// 关闭标签页
@ -202,8 +221,82 @@
pageTitle(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: {
/**
* 初始化 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