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

463 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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" :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,
// 拖拽相关状态
isDragging: false,
dragTabId: null,
sortableInstance: null,
dragThreshold: 50,
}
},
beforeCreate() {
document.body.classList.add(window.process.platform)
},
mounted() {
window.__onDispatchEvent = (detail) => {
const {id, event} = detail
switch (event) {
// 创建标签页
case 'create':
this.tabs.push(Object.assign({
id,
title: '',
url: '',
icon: '',
state: 'loading'
}, detail))
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
// 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: {
/**
* 获取当前激活的标签页
* @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;
},
},
methods: {
/**
* 切换标签页
* @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
}
},
/**
* 初始化拖拽排序
*/
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)
}
}
},
}
Vue.createApp(App).mount('#app')
</script>
</body>
</html>