Tabs.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. <script setup lang="tsx">
  2. import { isEmpty } from 'lodash-es'
  3. import { NButton, NDropdown, NEllipsis, NScrollbar } from 'naive-ui'
  4. import {
  5. computed,
  6. defineComponent,
  7. nextTick,
  8. onBeforeUnmount,
  9. onMounted,
  10. reactive,
  11. ref,
  12. Transition,
  13. TransitionGroup,
  14. useTemplateRef,
  15. watch,
  16. } from 'vue'
  17. import { VueDraggable } from 'vue-draggable-plus'
  18. import { ButtonAnimation } from '@/components'
  19. import router from '@/router'
  20. import { usePreferencesStore } from '@/stores'
  21. import { useTabsStore } from '@/stores'
  22. import type { Tab, Key } from '@/stores'
  23. import type { DropdownOption } from 'naive-ui'
  24. import type { PropType } from 'vue'
  25. type ContextMenuActions = {
  26. close: () => void
  27. closeAll: () => void
  28. closeLeft: () => void
  29. closeOther: () => void
  30. closeRight: () => void
  31. pin: () => void
  32. keepalive: () => void
  33. lock: () => void
  34. }
  35. const scrollbarRef = useTemplateRef<InstanceType<typeof NScrollbar>>('scrollbarRef')
  36. const tabsStore = useTabsStore()
  37. const preferencesStore = usePreferencesStore()
  38. const {
  39. setTabActivePath,
  40. getTab,
  41. removeTab,
  42. updateTab,
  43. setTabs,
  44. getRemovableIdsBefore,
  45. getRemovableIdsAfter,
  46. getRemovableIdsOther,
  47. getRemovableIds,
  48. } = tabsStore
  49. const tabPinnedList = computed({
  50. get: () => tabsStore.tabs.filter((tab) => tab.pinned),
  51. set: (newPinnedTabs: Tab[]) => {
  52. const currentUnpinnedTabs = tabsStore.tabs.filter((tab) => !tab.pinned)
  53. setTabs([...newPinnedTabs, ...currentUnpinnedTabs])
  54. },
  55. })
  56. const tabUnPinnedList = computed({
  57. get: () => tabsStore.tabs.filter((tab) => !tab.pinned),
  58. set: (newUnpinnedTabs: Tab[]) => {
  59. const currentPinnedTabs = tabsStore.tabs.filter((tab) => tab.pinned)
  60. setTabs([...currentPinnedTabs, ...newUnpinnedTabs])
  61. },
  62. })
  63. const pendingActivePath = ref('')
  64. const tabBackgroundTransitionClasses = reactive({
  65. leaveToClass: '',
  66. enterFromClass: '',
  67. })
  68. const isTabDragging = ref(false)
  69. const showTabTooltip = ref(true)
  70. const showTabDropdown = ref(false)
  71. const tabContextMenu = ref<Tab | null>(null)
  72. const tabDropdownOptions = computed<DropdownOption[]>(() => {
  73. const targetTab = tabContextMenu.value
  74. if (isEmpty(targetTab)) {
  75. return []
  76. }
  77. const { id, componentName } = targetTab
  78. const { pinned, locked, keepAlive } = getTab(id) ?? {}
  79. return [
  80. {
  81. key: 'close',
  82. icon: () => <span class='iconify ph--x' />,
  83. label: '关闭',
  84. disabled: locked,
  85. },
  86. {
  87. key: 'closeOther',
  88. icon: () => <span class='iconify ph--arrows-out-line-horizontal' />,
  89. label: '关闭其他',
  90. disabled: isEmpty(getRemovableIdsOther(id)),
  91. },
  92. {
  93. key: 'closeLeft',
  94. icon: () => <span class='iconify ph--arrow-line-left' />,
  95. label: '关闭左侧',
  96. disabled: isEmpty(getRemovableIdsBefore(id)),
  97. },
  98. {
  99. key: 'closeRight',
  100. icon: () => <span class='iconify ph--arrow-line-right' />,
  101. label: '关闭右侧',
  102. disabled: isEmpty(getRemovableIdsAfter(id)),
  103. },
  104. {
  105. key: 'closeAll',
  106. icon: () => <span class='iconify ph--arrows-horizontal' />,
  107. label: '关闭所有',
  108. disabled: isEmpty(getRemovableIds()),
  109. },
  110. {
  111. key: 'pin',
  112. icon: () => (
  113. <span
  114. class={pinned ? 'iconify ph--push-pin-simple-slash' : 'iconify ph--push-pin-simple'}
  115. />
  116. ),
  117. label: pinned ? '取消固定' : '固定标签页',
  118. },
  119. {
  120. key: 'keepalive',
  121. icon: () => (
  122. <span
  123. class={
  124. keepAlive ? 'iconify-[hugeicons--database-02]' : 'iconify-[hugeicons--database-locked]'
  125. }
  126. />
  127. ),
  128. label: keepAlive ? '取消缓存' : '缓存标签页',
  129. disabled: isEmpty(componentName),
  130. },
  131. {
  132. key: 'lock',
  133. icon: () => (
  134. <span class={locked ? 'iconify ph--lock-simple-open' : 'iconify ph--lock-simple'} />
  135. ),
  136. label: locked ? '取消锁定' : '锁定标签页',
  137. disabled: pinned,
  138. },
  139. ]
  140. })
  141. const dropdownPosition = reactive({
  142. x: 0,
  143. y: 0,
  144. })
  145. const handleTabClick = (path: string) => {
  146. setTabActivePath(path)
  147. }
  148. const handleTabCloseClick = (id: Key) => {
  149. removeTab(id)
  150. }
  151. const handleTabContextMenuClick = (e: MouseEvent, tab: Tab) => {
  152. e.preventDefault()
  153. tabContextMenu.value = tab
  154. showTabDropdown.value = false
  155. showTabTooltip.value = false
  156. nextTick(() => {
  157. showTabDropdown.value = true
  158. dropdownPosition.x = e.clientX
  159. dropdownPosition.y = e.clientY
  160. })
  161. }
  162. const onTabDropdownClickOutside = () => {
  163. showTabDropdown.value = false
  164. showTabTooltip.value = true
  165. tabContextMenu.value = null
  166. }
  167. const onTabDraggableStart = () => {
  168. showTabDropdown.value = false
  169. showTabTooltip.value = false
  170. }
  171. const onTabDraggableEnd = () => {
  172. showTabTooltip.value = true
  173. }
  174. const onTabDraggableChoose = () => {
  175. isTabDragging.value = true
  176. }
  177. const onTabDraggableUnchoose = () => {
  178. isTabDragging.value = false
  179. }
  180. const onTabDropdownSelected = (key: keyof ContextMenuActions) => {
  181. showTabDropdown.value = false
  182. showTabTooltip.value = true
  183. getTabContextMenuActions()?.[key]()
  184. }
  185. const onScrollbarWheeled = (e: WheelEvent) => {
  186. if (!scrollbarRef.value) return
  187. scrollbarRef.value.scrollBy({
  188. left: (e.deltaY || e.deltaX) * 3,
  189. behavior: 'smooth',
  190. })
  191. }
  192. function getTabContextMenuActions(): ContextMenuActions | null {
  193. const tabValue = tabContextMenu.value
  194. if (!tabValue) {
  195. return null
  196. }
  197. const { id } = tabValue
  198. const { locked, keepAlive, pinned } = getTab(id) ?? {}
  199. return {
  200. close: () => {
  201. removeTab(id)
  202. },
  203. closeOther: () => {
  204. removeTab(getRemovableIdsOther(id))
  205. },
  206. closeLeft: () => {
  207. removeTab(getRemovableIdsBefore(id))
  208. },
  209. closeRight: () => {
  210. removeTab(getRemovableIdsAfter(id))
  211. },
  212. closeAll: () => {
  213. removeTab(getRemovableIds())
  214. },
  215. pin: () => {
  216. updateTab(id, { pinned: !pinned })
  217. },
  218. keepalive: () => {
  219. updateTab(id, { keepAlive: !keepAlive })
  220. },
  221. lock: () => {
  222. updateTab(id, { locked: !locked })
  223. },
  224. }
  225. }
  226. function scrollToActiveTab(behavior: ScrollBehavior = 'auto') {
  227. nextTick(() => {
  228. document.querySelector('.tab-active')?.scrollIntoView({
  229. behavior,
  230. })
  231. })
  232. }
  233. function handleTabRefresh() {
  234. if (!preferencesStore.preferences.shouldRefreshTab) {
  235. preferencesStore.modify({
  236. shouldRefreshTab: true,
  237. })
  238. }
  239. }
  240. const routerAfterEach = router.afterEach(() => {
  241. nextTick(() => {
  242. pendingActivePath.value = tabsStore.tabActivePath
  243. })
  244. })
  245. const CompTabs = defineComponent({
  246. props: {
  247. modelValue: {
  248. type: Array as PropType<Tab[]>,
  249. required: true,
  250. },
  251. },
  252. emits: ['update:modelValue'],
  253. setup(props, { emit }) {
  254. return () => (
  255. <VueDraggable
  256. class='flex'
  257. modelValue={props.modelValue}
  258. animation={150}
  259. easing='cubic-bezier(0, 0, 1, 1)'
  260. direction='horizontal'
  261. scrollSensitivity={100}
  262. ghostClass='bg-primary/30'
  263. onStart={onTabDraggableStart}
  264. onEnd={onTabDraggableEnd}
  265. onChoose={onTabDraggableChoose}
  266. onUnchoose={onTabDraggableUnchoose}
  267. {...{
  268. 'onUpdate:modelValue': (value: Tab[]) => emit('update:modelValue', value),
  269. }}
  270. >
  271. <TransitionGroup
  272. duration={300}
  273. type='transition'
  274. enterFromClass='max-w-0'
  275. leaveToClass='max-w-0'
  276. onAfterEnter={() => scrollToActiveTab('smooth')}
  277. >
  278. {props.modelValue.map((tab) => (
  279. <div
  280. key={tab.id}
  281. class={[
  282. 'relative cursor-pointer overflow-hidden border-r border-r-naive-border transition-[background-color,border-color,max-width] hover:bg-primary/6 [&:not(.max-w-0)]:max-w-48',
  283. {
  284. 'tab-active': tab.path === pendingActivePath.value,
  285. group: !tab.locked && !preferencesStore.preferences.showTabClose,
  286. },
  287. ]}
  288. onClick={() => handleTabClick(tab.path)}
  289. onContextmenu={(e) => handleTabContextMenuClick(e, tab)}
  290. >
  291. <Transition
  292. type='transition'
  293. leaveActiveClass='transition-[opacity,scale,translate] will-change-[opacity,transform,scale]'
  294. enterActiveClass='transition-[opacity,scale,translate] will-change-[opacity,transform,scale]'
  295. leaveToClass={tabBackgroundTransitionClasses.leaveToClass}
  296. enterFromClass={tabBackgroundTransitionClasses.enterFromClass}
  297. onAfterEnter={() => {
  298. scrollToActiveTab('smooth')
  299. }}
  300. >
  301. {tab.path === pendingActivePath.value && (
  302. <div class='absolute inset-0 z-0 size-full border-t-[1.5px] border-primary bg-primary/6' />
  303. )}
  304. </Transition>
  305. <div class={['flex items-center py-2.5 pl-4', tab.pinned ? 'pr-4' : 'pr-2.5']}>
  306. <div
  307. class={[
  308. 'flex flex-1 items-center overflow-hidden transition-[translate]',
  309. {
  310. 'translate-x-2.5':
  311. !tab.pinned && (tab.locked || !preferencesStore.preferences.showTabClose),
  312. 'group-hover:translate-x-0':
  313. !tab.pinned && !tab.locked && !preferencesStore.preferences.showTabClose,
  314. },
  315. ]}
  316. >
  317. <div class='mr-2 grid shrink-0 place-items-center overflow-hidden'>
  318. <span
  319. class={[
  320. tab.icon,
  321. {
  322. 'text-primary': tab.componentName && getTab(tab.id)?.keepAlive,
  323. },
  324. ]}
  325. />
  326. </div>
  327. <NEllipsis tooltip={showTabTooltip.value}>{tab.title}</NEllipsis>
  328. </div>
  329. {!tab.pinned && (
  330. <div
  331. class={[
  332. 'ml-1 flex overflow-hidden transition-[opacity,scale]',
  333. {
  334. 'scale-0 opacity-0':
  335. tab.locked || !preferencesStore.preferences.showTabClose,
  336. 'group-hover:scale-100 group-hover:opacity-100':
  337. !tab.locked && !preferencesStore.preferences.showTabClose,
  338. },
  339. ]}
  340. >
  341. <NButton
  342. quaternary
  343. circle
  344. size='tiny'
  345. onClick={(e) => {
  346. e.stopPropagation()
  347. handleTabCloseClick(tab.id)
  348. }}
  349. disabled={tab.locked}
  350. >
  351. {{
  352. icon: () => <span class='iconify ph--x' />,
  353. }}
  354. </NButton>
  355. </div>
  356. )}
  357. </div>
  358. </div>
  359. ))}
  360. </TransitionGroup>
  361. </VueDraggable>
  362. )
  363. },
  364. })
  365. watch(
  366. (): [Tab[], string] => [tabsStore.tabs, tabsStore.tabActivePath],
  367. ([newTabs, newTabActivePath], [oldTabs, oldTabActivePath]) => {
  368. if (!newTabActivePath) {
  369. tabBackgroundTransitionClasses.leaveToClass = 'scale-0 opacity-0'
  370. return
  371. }
  372. if (!oldTabActivePath) {
  373. tabBackgroundTransitionClasses.enterFromClass = 'scale-0 opacity-0'
  374. return
  375. }
  376. const oldActiveIndex = oldTabs.findIndex((item) => item.path === oldTabActivePath)
  377. const newActiveIndex = newTabs.findIndex((item) => item.path === newTabActivePath)
  378. if (oldActiveIndex > newActiveIndex && newActiveIndex !== -1) {
  379. tabBackgroundTransitionClasses.leaveToClass = '-translate-x-full'
  380. tabBackgroundTransitionClasses.enterFromClass = 'translate-x-full'
  381. } else {
  382. tabBackgroundTransitionClasses.leaveToClass = 'translate-x-full'
  383. tabBackgroundTransitionClasses.enterFromClass = '-translate-x-full'
  384. }
  385. },
  386. )
  387. onMounted(() => {
  388. scrollToActiveTab()
  389. pendingActivePath.value = tabsStore.tabActivePath
  390. tabBackgroundTransitionClasses.enterFromClass = 'scale-0 opacity-0'
  391. })
  392. onBeforeUnmount(() => {
  393. routerAfterEach()
  394. })
  395. </script>
  396. <template>
  397. <div
  398. class="flex min-h-0 overflow-hidden border-b border-naive-border bg-naive-card transition-[background-color,border-color] select-none"
  399. >
  400. <CompTabs v-model="tabPinnedList" />
  401. <NScrollbar
  402. ref="scrollbarRef"
  403. x-scrollable
  404. @wheel.passive="onScrollbarWheeled"
  405. >
  406. <CompTabs v-model="tabUnPinnedList" />
  407. </NScrollbar>
  408. <div class="flex items-center px-3">
  409. <ButtonAnimation
  410. title="刷新"
  411. @click="handleTabRefresh"
  412. animation="rotate"
  413. >
  414. <span class="iconify size-5 ph--arrows-clockwise"></span>
  415. </ButtonAnimation>
  416. </div>
  417. <NDropdown
  418. placement="bottom-start"
  419. trigger="manual"
  420. :x="dropdownPosition.x"
  421. :y="dropdownPosition.y"
  422. :options="tabDropdownOptions"
  423. :show="showTabDropdown"
  424. @clickoutside="onTabDropdownClickOutside"
  425. @select="onTabDropdownSelected"
  426. >
  427. </NDropdown>
  428. </div>
  429. </template>