Files
voidraft/frontend/src/components/toolbar/DocumentSelector.vue

688 lines
20 KiB
Vue
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.
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useDocumentStore } from '@/stores/documentStore';
import { useTabStore } from '@/stores/tabStore';
import { useWindowStore } from '@/stores/windowStore';
import { useI18n } from 'vue-i18n';
import type { Document } from '@/../bindings/voidraft/internal/models/models';
const documentStore = useDocumentStore();
const tabStore = useTabStore();
const windowStore = useWindowStore();
const { t } = useI18n();
// 组件状态
const inputValue = ref('');
const inputRef = ref<HTMLInputElement>();
const editingId = ref<number | null>(null);
const editingTitle = ref('');
const editInputRef = ref<HTMLInputElement>();
const deleteConfirmId = ref<number | null>(null);
// 常量
const MAX_TITLE_LENGTH = 50;
// 计算属性
const currentDocName = computed(() => {
if (!documentStore.currentDocument) return t('toolbar.selectDocument');
const title = documentStore.currentDocument.title;
return title.length > 12 ? title.substring(0, 12) + '...' : title;
});
const filteredItems = computed(() => {
const docs = documentStore.documentList;
const query = inputValue.value.trim();
if (!query) return docs;
const filtered = docs.filter(doc =>
doc.title.toLowerCase().includes(query.toLowerCase())
);
// 如果输入的不是已存在文档的完整标题,添加创建选项
const exactMatch = docs.some(doc => doc.title.toLowerCase() === query.toLowerCase());
if (!exactMatch && query.length > 0) {
return [
{ id: -1, title: t('toolbar.createDocument') + ` "${query}"`, isCreateOption: true } as any,
...filtered
];
}
return filtered;
});
// 工具函数
const validateTitle = (title: string): string | null => {
if (!title.trim()) return t('toolbar.documentNameRequired');
if (title.trim().length > MAX_TITLE_LENGTH) {
return t('toolbar.documentNameTooLong', { max: MAX_TITLE_LENGTH });
}
return null;
};
const formatTime = (dateString: string | null) => {
if (!dateString) return t('toolbar.unknownTime');
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return t('toolbar.invalidDate');
const locale = t('locale') === 'zh-CN' ? 'zh-CN' : 'en-US';
return date.toLocaleString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} catch {
return t('toolbar.timeError');
}
};
// 核心操作
const openMenu = async () => {
documentStore.openDocumentSelector();
await documentStore.getDocumentMetaList();
await nextTick();
inputRef.value?.focus();
};
const closeMenu = () => {
documentStore.closeDocumentSelector();
inputValue.value = '';
editingId.value = null;
editingTitle.value = '';
deleteConfirmId.value = null;
};
const selectDoc = async (doc: Document) => {
// 如果选择的就是当前文档,直接关闭菜单
if (documentStore.currentDocument?.id === doc.id) {
closeMenu();
return;
}
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
if (hasOpen) {
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
return;
}
const success = await documentStore.openDocument(doc.id);
if (success) {
if (tabStore.isTabsEnabled) {
tabStore.addOrActivateTab(doc);
}
closeMenu();
}
};
const createDoc = async (title: string) => {
const trimmedTitle = title.trim();
const error = validateTitle(trimmedTitle);
if (error) return;
try {
const newDoc = await documentStore.createNewDocument(trimmedTitle);
if (newDoc) await selectDoc(newDoc);
} catch (error) {
console.error('Failed to create document:', error);
}
};
const selectItem = async (item: any) => {
if (item.isCreateOption) {
await createDoc(inputValue.value.trim());
} else {
await selectDoc(item);
}
};
// 编辑操作
const startRename = (doc: Document, event: Event) => {
event.stopPropagation();
editingId.value = doc.id;
editingTitle.value = doc.title;
deleteConfirmId.value = null;
nextTick(() => {
editInputRef.value?.focus();
editInputRef.value?.select();
});
};
const saveEdit = async () => {
if (!editingId.value || !editingTitle.value.trim()) {
editingId.value = null;
editingTitle.value = '';
return;
}
const trimmedTitle = editingTitle.value.trim();
const error = validateTitle(trimmedTitle);
if (error) return;
try {
await documentStore.updateDocumentMetadata(editingId.value, trimmedTitle);
await documentStore.getDocumentMetaList();
// 如果tabs功能开启且该文档有标签页更新标签页标题
if (tabStore.isTabsEnabled && tabStore.hasTab(editingId.value)) {
tabStore.updateTabTitle(editingId.value, trimmedTitle);
}
} catch (error) {
console.error('Failed to update document:', error);
} finally {
editingId.value = null;
editingTitle.value = '';
}
};
// 其他操作
const openInNewWindow = async (doc: Document, event: Event) => {
event.stopPropagation();
try {
await documentStore.openDocumentInNewWindow(doc.id);
} catch (error) {
console.error('Failed to open document in new window:', error);
}
};
const handleDelete = async (doc: Document, event: Event) => {
event.stopPropagation();
if (deleteConfirmId.value === doc.id) {
// 确认删除前检查文档是否在其他窗口打开
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
if (hasOpen) {
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
deleteConfirmId.value = null;
return;
}
const deleteSuccess = await documentStore.deleteDocument(doc.id);
if (deleteSuccess) {
await documentStore.getDocumentMetaList();
// 如果删除的是当前文档,切换到第一个文档
if (documentStore.currentDocument?.id === doc.id && documentStore.documentList.length > 0) {
const firstDoc = documentStore.documentList[0];
if (firstDoc) await selectDoc(firstDoc);
}
}
deleteConfirmId.value = null;
} else {
// 进入确认状态
deleteConfirmId.value = doc.id;
editingId.value = null;
// 3秒后自动取消确认状态
setTimeout(() => {
if (deleteConfirmId.value === doc.id) {
deleteConfirmId.value = null;
}
}, 3000);
}
};
// 键盘事件处理
const createKeyHandler = (handlers: Record<string, () => void>) => (event: KeyboardEvent) => {
const handler = handlers[event.key];
if (handler) {
event.preventDefault();
event.stopPropagation();
handler();
}
};
const handleGlobalKeydown = createKeyHandler({
Escape: () => {
if (editingId.value) {
editingId.value = null;
editingTitle.value = '';
} else if (deleteConfirmId.value) {
deleteConfirmId.value = null;
} else {
closeMenu();
}
}
});
const handleInputKeydown = createKeyHandler({
Enter: () => {
const query = inputValue.value.trim();
if (query && filteredItems.value.length > 0) {
selectItem(filteredItems.value[0]);
}
},
Escape: closeMenu
});
const handleEditKeydown = createKeyHandler({
Enter: saveEdit,
Escape: () => {
editingId.value = null;
editingTitle.value = '';
}
});
// 点击外部关闭
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest('.document-selector')) {
closeMenu();
}
};
// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleGlobalKeydown);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleGlobalKeydown);
});
// 监听菜单状态变化
watch(() => documentStore.showDocumentSelector, (isOpen) => {
if (isOpen) {
openMenu();
}
});
</script>
<template>
<div class="document-selector">
<!-- 选择器按钮 -->
<button class="doc-btn" @click="documentStore.toggleDocumentSelector">
<span class="doc-icon">
<svg 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="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</span>
<span class="doc-name">{{ currentDocName }}</span>
<span class="arrow" :class="{ open: documentStore.showDocumentSelector }"></span>
</button>
<!-- 菜单 -->
<div v-if="documentStore.showDocumentSelector" class="doc-menu">
<!-- 输入框 -->
<div class="input-box">
<input
ref="inputRef"
v-model="inputValue"
type="text"
class="main-input"
:placeholder="t('toolbar.searchOrCreateDocument')"
:maxlength="MAX_TITLE_LENGTH"
@keydown="handleInputKeydown"
/>
<svg class="input-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">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<!-- 项目列表 -->
<div class="item-list">
<div
v-for="item in filteredItems"
:key="item.id"
class="list-item"
:class="{
'active': !item.isCreateOption && documentStore.currentDocument?.id === item.id,
'create-item': item.isCreateOption
}"
@click="selectItem(item)"
>
<!-- 创建选项 -->
<div v-if="item.isCreateOption" class="create-option">
<svg 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="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
<span>{{ item.title }}</span>
</div>
<!-- 文档项 -->
<div v-else class="doc-item-content">
<!-- 普通显示 -->
<div v-if="editingId !== item.id" class="doc-info">
<div class="doc-title">{{ item.title }}</div>
<!-- 根据状态显示错误信息或时间 -->
<div v-if="documentStore.selectorError?.docId === item.id" class="doc-error">
{{ documentStore.selectorError?.message }}
</div>
<div v-else class="doc-date">{{ formatTime(item.updatedAt) }}</div>
</div>
<!-- 编辑状态 -->
<div v-else class="doc-edit">
<input
:ref="el => editInputRef = el as HTMLInputElement"
v-model="editingTitle"
type="text"
class="edit-input"
:maxlength="MAX_TITLE_LENGTH"
@keydown="handleEditKeydown"
@blur="saveEdit"
@click.stop
/>
</div>
<!-- 操作按钮 -->
<div v-if="editingId !== item.id" class="doc-actions">
<!-- 只有非当前文档才显示在新窗口打开按钮 -->
<button
v-if="documentStore.currentDocument?.id !== item.id"
class="action-btn"
@click="openInNewWindow(item, $event)"
:title="t('toolbar.openInNewWindow')"
>
<svg width="12" height="12" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path
d="M172.8 1017.6c-89.6 0-166.4-70.4-166.4-166.4V441.6c0-89.6 70.4-166.4 166.4-166.4h416c89.6 0 166.4 70.4 166.4 166.4v416c0 89.6-70.4 166.4-166.4 166.4l-416-6.4z m0-659.2c-51.2 0-89.6 38.4-89.6 89.6v416c0 51.2 38.4 89.6 89.6 89.6h416c51.2 0 89.6-38.4 89.6-89.6V441.6c0-51.2-38.4-89.6-89.6-89.6H172.8z"></path>
<path
d="M851.2 19.2H435.2C339.2 19.2 268.8 96 268.8 185.6v25.6h70.4v-25.6c0-51.2 38.4-89.6 89.6-89.6h409.6c51.2 0 89.6 38.4 89.6 89.6v409.6c0 51.2-38.4 89.6-89.6 89.6h-38.4V768h51.2c96 0 166.4-76.8 166.4-166.4V185.6c0-96-76.8-166.4-166.4-166.4z"></path>
</svg>
</button>
<button class="action-btn" @click="startRename(item, $event)" :title="t('toolbar.rename')">
<svg 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="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path>
</svg>
</button>
<button
v-if="documentStore.documentList.length > 1 && item.id !== 1"
class="action-btn delete-btn"
:class="{ 'delete-confirm': deleteConfirmId === item.id }"
@click="handleDelete(item, $event)"
:title="deleteConfirmId === item.id ? t('toolbar.confirmDelete') : t('toolbar.delete')"
>
<svg v-if="deleteConfirmId !== item.id" 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">
<polyline points="3,6 5,6 21,6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span v-else class="confirm-text">{{ t('toolbar.confirm') }}</span>
</button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredItems.length === 0" class="empty">
{{ t('toolbar.noDocumentFound') }}
</div>
<!-- 加载状态 -->
<div v-if="documentStore.isLoading" class="loading">
{{ t('toolbar.loading') }}
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.document-selector {
position: relative;
.doc-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 11px;
display: flex;
align-items: center;
gap: 3px;
padding: 2px 4px;
border-radius: 3px;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
.doc-icon {
display: flex;
align-items: center;
}
.doc-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow {
font-size: 8px;
margin-left: 2px;
transition: transform 0.2s ease;
&.open {
transform: rotate(180deg);
}
}
}
.doc-menu {
position: absolute;
bottom: 100%;
right: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
margin-bottom: 4px;
width: 260px;
max-height: calc(100vh - 40px); // 限制最大高度留出titlebar空间(32px)和一些边距
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
.input-box {
position: relative;
padding: 8px;
border-bottom: 1px solid var(--border-color);
.main-input {
width: 100%;
box-sizing: border-box;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 5px 8px 5px 26px;
font-size: 11px;
color: var(--text-primary);
outline: none;
&:focus {
border-color: var(--text-muted);
}
&::placeholder {
color: var(--text-muted);
}
}
.input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
}
.item-list {
max-height: calc(100vh - 100px); // 为输入框和边距预留空间
overflow-y: auto;
flex: 1;
.list-item {
cursor: pointer;
border-bottom: 1px solid var(--border-color);
&:hover {
background-color: var(--bg-hover);
}
&.active {
background-color: var(--selection-bg);
.doc-item-content .doc-info {
.doc-title {
color: var(--selection-text);
}
.doc-date, .doc-error {
color: var(--selection-text);
opacity: 0.7;
}
}
}
&.create-item {
.create-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px;
font-size: 11px;
font-weight: normal;
svg {
flex-shrink: 0;
color: var(--text-muted);
}
}
}
.doc-item-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px;
.doc-info {
flex: 1;
min-width: 0;
.doc-title {
font-size: 12px;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: normal;
}
.doc-date {
font-size: 10px;
color: var(--text-muted);
opacity: 0.6;
}
.doc-error {
font-size: 10px;
color: var(--text-danger);
font-weight: 500;
animation: fadeInOut 3s forwards;
}
}
.doc-edit {
flex: 1;
.edit-input {
width: 100%;
box-sizing: border-box;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 4px 6px;
font-size: 11px;
color: var(--text-primary);
outline: none;
&:focus {
border-color: var(--text-muted);
}
}
}
.doc-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.2s ease;
.action-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: var(--bg-hover);
color: var(--text-secondary);
}
&.delete-btn {
&:hover {
color: var(--text-danger);
}
&.delete-confirm {
background-color: var(--text-danger);
color: white;
.confirm-text {
font-size: 9px;
font-weight: 500;
}
}
}
}
}
}
&:hover .doc-actions {
opacity: 1;
}
}
.empty, .loading {
padding: 16px 8px;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
}
}
}
@keyframes fadeInOut {
0% { opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { opacity: 0; }
}
</style>