kuaifan 9d62ec1ec1 feat: 添加标签页拖拽排序功能
- 引入 Sortable.js 库以支持标签页的拖拽排序
- 实现标签页的动态插入和顺序重排
- 更新样式以适应拖拽效果
- 增加 IPC 通信以同步标签页顺序变化
- 优化标签页创建和关闭逻辑,提升用户体验
2026-01-09 15:46:02 +08:00

436 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<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">
<div class="nav">
<div class="nav-controls">
<div class="nav-back" :class="{disabled: !canGoBack}" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</div>
<div class="nav-forward" :class="{disabled: !canGoForward}" @click="goForward">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</div>
<div class="nav-refresh" @click="loadingState ? stop() : refresh()">
<svg v-if="loadingState" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" style="transform:scale(0.99)" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
</div>
</div>
<ul class="nav-tabs">
<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>
<div v-else class="tab-icon background" :style="iconStyle(item)"></div>
<div class="tab-title" :title="item.title">{{tabTitle(item)}}</div>
<div class="tab-close" @click.stop="onClose(item)"></div>
</li>
</ul>
<div v-if="canBrowser" class="nav-browser" @click="onBrowser">
<span></span>
</div>
</div>
</div>
<script>
const App = {
data() {
return {
// 当前激活的标签页ID
activeId: 0,
// 标签页列表
tabs: [],
// 停止定时器
stopTimer: null,
// 是否可以后退
canGoBack: false,
// 是否可以前进
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':
const newTab = Object.assign({
id,
title: '',
url: '',
icon: '',
state: 'loading'
}, 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
// 关闭标签页
case 'close':
const closeIndex = this.tabs.findIndex(item => item.id === id)
if (closeIndex > -1) {
this.tabs.splice(closeIndex, 1)
}
break
// 切换标签页
case 'switch':
this.activeId = id
this.scrollTabActive()
this.updateNavigationState()
break
// 页面标题
case 'title':
if (["HitoseaTask", "DooTask", "about:blank"].includes(detail.title)) {
return
}
const titleItem = this.tabs.find(item => item.id === id)
if (titleItem) {
titleItem.title = detail.title
titleItem.url = detail.url
}
break
// 页面图标
case 'favicon':
const faviconItem = this.tabs.find(item => item.id === id)
if (faviconItem) {
faviconItem.icon = detail.favicons[detail.favicons.length - 1]
//
const img = new Image();
img.onerror = () => {
faviconItem.icon = ''
};
img.src = faviconItem.icon
}
break
// 开始加载
case 'start-loading':
const startItem = this.tabs.find(item => item.id === id)
if (startItem) {
this.stopTimer && clearTimeout(this.stopTimer)
startItem.state = 'loading'
}
break
// 停止加载
case 'stop-loading':
this.stopTimer = setTimeout(_ => {
const stopItem = this.tabs.find(item => item.id === id)
if (stopItem) {
stopItem.state = 'loaded'
}
if (id === this.activeId) {
this.updateNavigationState()
}
}, 300)
break
// 导航状态
case 'navigation-state':
if (id === this.activeId) {
this.canGoBack = detail.canGoBack
this.canGoForward = detail.canGoForward
}
break
// 进入全屏
case 'enter-full-screen':
document.body.classList.add('full-screen')
break
// 离开全屏
case 'leave-full-screen':
document.body.classList.remove('full-screen')
break
}
}
window.__openDevTools = () => {
this.sendMessage('webTabOpenDevTools')
}
},
computed: {
/**
* 获取当前激活的标签页
* @returns {object|null}
*/
activeItem() {
if (this.tabs.length === 0) {
return null
}
return this.tabs.find(item => item.id === this.activeId)
},
/**
* 获取页面标题
* @returns {string}
*/
pageTitle() {
return this.activeItem ? this.activeItem.title : 'Untitled'
},
/**
* 是否可以打开浏览器
* @returns {boolean}
*/
canBrowser() {
return !(this.activeItem && this.isLocalHost(this.activeItem.url))
},
/**
* 获取加载状态
* @returns {boolean}
*/
loadingState() {
return this.activeItem ? this.activeItem.state === 'loading' : false
}
},
watch: {
/**
* 监听页面标题
* @param title
*/
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
*/
onSwitch(item) {
this.sendMessage('webTabActivate', item.id)
},
/**
* 关闭标签页
* @param item
*/
onClose(item) {
this.sendMessage('webTabClose', item.id);
},
/**
* 打开浏览器
*/
onBrowser() {
this.sendMessage('webTabExternal')
},
/**
* 获取标签页图标样式
* @param item
* @returns {string}
*/
iconStyle(item) {
return item.icon ? `background-image: url(${item.icon})` : ''
},
/**
* 获取标签页标题
* @param item
* @returns {string}
*/
tabTitle(item) {
if (item.title) {
return item.title
}
if (item.state === 'loading') {
return 'Loading...'
}
if (item.url) {
if (/localhost:/.test(item.url)) {
return 'Loading...'
}
return `${item.url}`.replace(/^https?:\/\//, '')
}
},
/**
* 滚动到当前激活的标签页
*/
scrollTabActive() {
setTimeout(() => {
try {
const child = document.querySelector(`.nav-tabs li[data-id="${this.activeId}"]`)
if (child) {
child.scrollIntoView({behavior: 'smooth', block: 'nearest'})
}
} catch (e) {
//
}
}, 0)
},
/**
* 发送消息
* @param event
* @param args
*/
sendMessage(event, args) {
electron?.sendMessage(event, args)
},
/**
* 后退
*/
goBack() {
if (!this.canGoBack) return
this.sendMessage('webTabGoBack')
},
/**
* 前进
*/
goForward() {
if (!this.canGoForward) return
this.sendMessage('webTabGoForward')
},
/**
* 停止
*/
stop() {
this.sendMessage('webTabStop')
},
/**
* 刷新
*/
refresh() {
this.sendMessage('webTabReload')
},
/**
* 更新导航状态
*/
updateNavigationState() {
this.sendMessage('webTabGetNavigationState')
},
/**
* 判断是否是本地URL
* @param url
* @returns {boolean}
*/
isLocalHost(url) {
if (!url) {
return false
}
try {
const uri = new URL(url)
return uri.hostname == "localhost"
} catch (e) {
return false
}
}
},
}
Vue.createApp(App).mount('#app')
</script>
</body>
</html>