✨ Added tab functionality and optimized related configurations
This commit is contained in:
@@ -1,127 +1,105 @@
|
||||
<template>
|
||||
<div class="tab-container" style="--wails-draggable:no-drag">
|
||||
<div class="tab-container" style="--wails-draggable:drag">
|
||||
<div class="tab-bar" ref="tabBarRef">
|
||||
<div class="tab-scroll-wrapper" ref="tabScrollWrapperRef" style="--wails-draggable:no-drag" @wheel.prevent.stop="onWheelScroll">
|
||||
<div class="tab-scroll-wrapper" ref="tabScrollWrapperRef" style="--wails-draggable:drag" @wheel.prevent.stop="onWheelScroll">
|
||||
<div class="tab-list" ref="tabListRef">
|
||||
<TabItem
|
||||
v-for="tab in mockTabs"
|
||||
:key="tab.id"
|
||||
v-for="tab in tabStore.tabs"
|
||||
:key="tab.documentId"
|
||||
:tab="tab"
|
||||
:is-active="tab.id === activeTabId"
|
||||
@click="switchToTab(tab.id)"
|
||||
@close="closeTab(tab.id)"
|
||||
@dragstart="onDragStart($event, tab.id)"
|
||||
:isActive="tab.documentId === tabStore.currentDocumentId"
|
||||
:canClose="tabStore.canCloseTab"
|
||||
@click="switchToTab"
|
||||
@close="closeTab"
|
||||
@dragstart="onDragStart"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop($event, tab.id)"
|
||||
@drop="onDrop"
|
||||
@contextmenu="onContextMenu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单占位 -->
|
||||
<div v-if="showContextMenu" class="context-menu-placeholder">
|
||||
<!-- 这里将来会放置 TabContextMenu 组件 -->
|
||||
</div>
|
||||
<!-- 右键菜单 -->
|
||||
<TabContextMenu
|
||||
:visible="showContextMenu"
|
||||
:position="contextMenuPosition"
|
||||
:targetDocumentId="contextMenuTargetId"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import TabItem from './TabItem.vue';
|
||||
import TabContextMenu from './TabContextMenu.vue';
|
||||
import { useTabStore } from '@/stores/tabStore';
|
||||
|
||||
// 模拟数据接口
|
||||
interface MockTab {
|
||||
id: number;
|
||||
title: string;
|
||||
isDirty: boolean;
|
||||
isActive: boolean;
|
||||
documentId: number;
|
||||
}
|
||||
|
||||
// 模拟标签页数据
|
||||
const mockTabs = ref<MockTab[]>([
|
||||
{ id: 1, title: 'Document 1', isDirty: false, isActive: true, documentId: 1 },
|
||||
{ id: 2, title: 'Long Document Name Example', isDirty: true, isActive: false, documentId: 2 },
|
||||
{ id: 3, title: 'README.md', isDirty: false, isActive: false, documentId: 3 },
|
||||
{ id: 4, title: 'config.json', isDirty: true, isActive: false, documentId: 4 },
|
||||
{ id: 5, title: 'Another Very Long Document Title', isDirty: false, isActive: false, documentId: 5 },
|
||||
{ id: 6, title: 'package.json', isDirty: false, isActive: false, documentId: 6 },
|
||||
{ id: 7, title: 'index.html', isDirty: true, isActive: false, documentId: 7 },
|
||||
{ id: 8, title: 'styles.css', isDirty: false, isActive: false, documentId: 8 },
|
||||
{ id: 9, title: 'main.js', isDirty: false, isActive: false, documentId: 9 },
|
||||
{ id: 10, title: 'utils.ts', isDirty: true, isActive: false, documentId: 10 },
|
||||
]);
|
||||
|
||||
const activeTabId = ref(1);
|
||||
const showContextMenu = ref(false);
|
||||
const tabStore = useTabStore();
|
||||
|
||||
// DOM 引用
|
||||
const tabBarRef = ref<HTMLElement>();
|
||||
const tabListRef = ref<HTMLElement>();
|
||||
// 新增:滚动容器引用
|
||||
const tabScrollWrapperRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// 拖拽状态
|
||||
let draggedTabId: number | null = null;
|
||||
// 右键菜单状态
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 });
|
||||
const contextMenuTargetId = ref<number | null>(null);
|
||||
|
||||
// 切换标签页
|
||||
const switchToTab = (tabId: number) => {
|
||||
activeTabId.value = tabId;
|
||||
mockTabs.value.forEach(tab => {
|
||||
tab.isActive = tab.id === tabId;
|
||||
});
|
||||
console.log('Switch to tab:', tabId);
|
||||
|
||||
// 标签页操作
|
||||
const switchToTab = (documentId: number) => {
|
||||
tabStore.switchToTabAndDocument(documentId);
|
||||
};
|
||||
|
||||
// 关闭标签页
|
||||
const closeTab = (tabId: number) => {
|
||||
const index = mockTabs.value.findIndex(tab => tab.id === tabId);
|
||||
if (index === -1) return;
|
||||
|
||||
mockTabs.value.splice(index, 1);
|
||||
|
||||
// 如果关闭的是活跃标签页,切换到其他标签页
|
||||
if (activeTabId.value === tabId && mockTabs.value.length > 0) {
|
||||
const nextTab = mockTabs.value[Math.max(0, index - 1)];
|
||||
switchToTab(nextTab.id);
|
||||
}
|
||||
|
||||
console.log('Close tab:', tabId);
|
||||
const closeTab = (documentId: number) => {
|
||||
tabStore.closeTab(documentId);
|
||||
};
|
||||
|
||||
// 拖拽开始
|
||||
const onDragStart = (event: DragEvent, tabId: number) => {
|
||||
draggedTabId = tabId;
|
||||
event.dataTransfer?.setData('text/plain', tabId.toString());
|
||||
console.log('Drag start:', tabId);
|
||||
// 拖拽操作
|
||||
const onDragStart = (event: DragEvent, documentId: number) => {
|
||||
tabStore.draggedTabId = documentId;
|
||||
event.dataTransfer!.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
// 拖拽悬停
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer!.dropEffect = 'move';
|
||||
};
|
||||
|
||||
// 拖拽放置
|
||||
const onDrop = (event: DragEvent, targetTabId: number) => {
|
||||
const onDrop = (event: DragEvent, targetDocumentId: number) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (draggedTabId && draggedTabId !== targetTabId) {
|
||||
const draggedIndex = mockTabs.value.findIndex(tab => tab.id === draggedTabId);
|
||||
const targetIndex = mockTabs.value.findIndex(tab => tab.id === targetTabId);
|
||||
if (tabStore.draggedTabId && tabStore.draggedTabId !== targetDocumentId) {
|
||||
const draggedIndex = tabStore.getTabIndex(tabStore.draggedTabId);
|
||||
const targetIndex = tabStore.getTabIndex(targetDocumentId);
|
||||
|
||||
if (draggedIndex !== -1 && targetIndex !== -1) {
|
||||
const draggedTab = mockTabs.value.splice(draggedIndex, 1)[0];
|
||||
mockTabs.value.splice(targetIndex, 0, draggedTab);
|
||||
console.log('Reorder tabs:', draggedTabId, 'to position of', targetTabId);
|
||||
tabStore.moveTab(draggedIndex, targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
draggedTabId = null;
|
||||
tabStore.draggedTabId = null;
|
||||
};
|
||||
|
||||
// 右键菜单操作
|
||||
const onContextMenu = (event: MouseEvent, documentId: number) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
|
||||
contextMenuTargetId.value = documentId;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
showContextMenu.value = false;
|
||||
contextMenuTargetId.value = null;
|
||||
};
|
||||
|
||||
// 滚轮滚动处理
|
||||
const onWheelScroll = (event: WheelEvent) => {
|
||||
const el = tabScrollWrapperRef.value;
|
||||
if (!el) return;
|
||||
@@ -129,7 +107,6 @@ const onWheelScroll = (event: WheelEvent) => {
|
||||
el.scrollLeft += delta;
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
// 组件挂载时的初始化逻辑
|
||||
});
|
||||
@@ -206,18 +183,6 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-placeholder {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.tab-action-btn {
|
||||
|
||||
228
frontend/src/components/tabs/TabContextMenu.vue
Normal file
228
frontend/src/components/tabs/TabContextMenu.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible && canClose"
|
||||
class="tab-context-menu"
|
||||
:style="{
|
||||
left: position.x + 'px',
|
||||
top: position.y + 'px'
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<div v-if="canClose" class="menu-item" @click="handleMenuClick('close')">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
<span class="menu-text">{{ t('tabs.contextMenu.closeTab') }}</span>
|
||||
</div>
|
||||
<div v-if="hasOtherTabs" class="menu-item" @click="handleMenuClick('closeOthers')">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18M3 12h18M3 18h18"/>
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
<span class="menu-text">{{ t('tabs.contextMenu.closeOthers') }}</span>
|
||||
</div>
|
||||
<div v-if="hasTabsToLeft" class="menu-item" @click="handleMenuClick('closeLeft')">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
<span class="menu-text">{{ t('tabs.contextMenu.closeLeft') }}</span>
|
||||
</div>
|
||||
<div v-if="hasTabsToRight" class="menu-item" @click="handleMenuClick('closeRight')">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
<span class="menu-text">{{ t('tabs.contextMenu.closeRight') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTabStore } from '@/stores/tabStore';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
targetDocumentId: number | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
// 计算属性
|
||||
const canClose = computed(() => tabStore.canCloseTab);
|
||||
|
||||
const hasOtherTabs = computed(() => {
|
||||
return tabStore.tabs.length > 1;
|
||||
});
|
||||
|
||||
const currentTabIndex = computed(() => {
|
||||
if (!props.targetDocumentId) return -1;
|
||||
return tabStore.getTabIndex(props.targetDocumentId);
|
||||
});
|
||||
|
||||
const hasTabsToRight = computed(() => {
|
||||
const index = currentTabIndex.value;
|
||||
return index !== -1 && index < tabStore.tabs.length - 1;
|
||||
});
|
||||
|
||||
const hasTabsToLeft = computed(() => {
|
||||
const index = currentTabIndex.value;
|
||||
return index > 0;
|
||||
});
|
||||
|
||||
// 处理菜单项点击
|
||||
const handleMenuClick = (action: string) => {
|
||||
if (!props.targetDocumentId) return;
|
||||
|
||||
switch (action) {
|
||||
case 'close':
|
||||
tabStore.closeTab(props.targetDocumentId);
|
||||
break;
|
||||
case 'closeOthers':
|
||||
tabStore.closeOtherTabs(props.targetDocumentId);
|
||||
break;
|
||||
case 'closeLeft':
|
||||
tabStore.closeTabsToLeft(props.targetDocumentId);
|
||||
break;
|
||||
case 'closeRight':
|
||||
tabStore.closeTabsToRight(props.targetDocumentId);
|
||||
break;
|
||||
}
|
||||
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// 处理外部点击
|
||||
const handleClickOutside = (_event: MouseEvent) => {
|
||||
if (props.visible) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理ESC键
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.visible) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
min-width: 140px;
|
||||
user-select: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s ease;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
.menu-item:hover & {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
white-space: nowrap;
|
||||
font-weight: 400;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 主题适配 */
|
||||
:root[data-theme="dark"] .tab-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.menu-item {
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: var(--text-secondary);
|
||||
|
||||
.menu-item:hover & {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .tab-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.menu-item {
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: var(--text-secondary);
|
||||
|
||||
.menu-item:hover & {
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -39,9 +39,10 @@
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<div
|
||||
v-if="props.canClose"
|
||||
class="tab-close"
|
||||
@click.stop="handleClose"
|
||||
:title="'关闭标签页'"
|
||||
:title="'Close tab'"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -70,25 +71,23 @@ import { computed, ref } from 'vue';
|
||||
// 组件属性
|
||||
interface TabProps {
|
||||
tab: {
|
||||
id: number;
|
||||
title: string;
|
||||
isDirty: boolean;
|
||||
isActive: boolean;
|
||||
documentId: number;
|
||||
documentId: number; // 直接使用文档ID作为唯一标识
|
||||
title: string; // 标签页标题
|
||||
};
|
||||
isActive: boolean;
|
||||
canClose?: boolean; // 是否可以关闭标签页
|
||||
}
|
||||
|
||||
const props = defineProps<TabProps>();
|
||||
|
||||
// 组件事件
|
||||
const emit = defineEmits<{
|
||||
click: [tabId: number];
|
||||
close: [tabId: number];
|
||||
dragstart: [event: DragEvent, tabId: number];
|
||||
click: [documentId: number];
|
||||
close: [documentId: number];
|
||||
dragstart: [event: DragEvent, documentId: number];
|
||||
dragover: [event: DragEvent];
|
||||
drop: [event: DragEvent, tabId: number];
|
||||
contextmenu: [event: MouseEvent, tabId: number];
|
||||
drop: [event: DragEvent, documentId: number];
|
||||
contextmenu: [event: MouseEvent, documentId: number];
|
||||
}>();
|
||||
|
||||
// 组件状态
|
||||
@@ -103,16 +102,16 @@ const displayTitle = computed(() => {
|
||||
|
||||
// 事件处理
|
||||
const handleClick = () => {
|
||||
emit('click', props.tab.id);
|
||||
emit('click', props.tab.documentId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close', props.tab.id);
|
||||
emit('close', props.tab.documentId);
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
isDragging.value = true;
|
||||
emit('dragstart', event, props.tab.id);
|
||||
emit('dragstart', event, props.tab.documentId);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
@@ -121,7 +120,7 @@ const handleDragOver = (event: DragEvent) => {
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false;
|
||||
emit('drop', event, props.tab.id);
|
||||
emit('drop', event, props.tab.documentId);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
@@ -130,7 +129,7 @@ const handleDragEnd = () => {
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
emit('contextmenu', event, props.tab.id);
|
||||
emit('contextmenu', event, props.tab.documentId);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -159,14 +158,36 @@ const handleContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
border-bottom: 2px solid var(--accent-color);
|
||||
background-color: var(--toolbar-button-active, var(--toolbar-button-hover));
|
||||
color: var(--toolbar-text);
|
||||
|
||||
position: relative;
|
||||
.tab-title {
|
||||
color: var(--toolbar-text);
|
||||
/* 不用加粗,避免抖动 */
|
||||
font-weight: 600; /* 字体加粗 */
|
||||
text-shadow: 0 0 1px rgba(0, 0, 0, 0.1); /* 轻微阴影增强可读性 */
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
color: var(--accent-color);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部活跃线条 */
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--tab-active-line);
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&.active::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
|
||||
Reference in New Issue
Block a user