feat: 添加快速预览、文件统计和虚拟滚动架构
## 新增功能 ### 1. 空格键快速预览 - 新增 QuickPreview.vue 组件,支持快速预览文件 - 按空格键打开预览,再次按空格或ESC关闭 - 支持照片、视频、文件夹、文件的预览界面 - 模态框带背景模糊效果和过渡动画 - 显示文件详细信息(大小、日期等) - 当前使用占位符,待后续接入真实API ### 2. 面包屑文件统计 - 实时显示当前文件夹的照片和视频数量 - 照片统计用蓝色图标🖼️,视频统计用粉色图标🎬 - 数量为0时自动隐藏对应统计 - 切换文件夹时实时更新统计数据 - 位于面包屑右侧,搜索框左侧 ### 3. 虚拟滚动基础架构 - 新增 useVirtualScroll composable,支持大量文件的高性能渲染 - useVirtualScroll: 列表模式虚拟滚动 - useVirtualGrid: 网格模式虚拟滚动 - 自动计算可见项目、偏移量和总高度 - 支持自定义 overscan 参数 - 为后续支持数万文件浏览做好准备 ## 技术改进 ### 组件优化 - 修改 useKeyboardNav 的 onSpace 回调类型,接受 KeyboardEvent 参数 - 在 FilePane 中统一管理空格键事件,避免预览组件重复监听 - 添加预览打开状态检测,正确处理打开/关闭切换 ### 类型定义 - 扩展 FileItem 类型定义 - 完善虚拟滚动相关的 TypeScript 类型 ### 样式优化 - 添加文件统计的独立样式(白色卡片、轻微阴影) - 预览组件样式(最大宽度900px、淡入淡出动画) - 响应式设计,适配不同屏幕尺寸 ## 依赖更新 - 安装 vue-virtual-scroller@next(虽然最终使用了自定义实现) ## 文档 - 新增 FEATURES.md 功能说明文档 - 包含详细的使用指南、技术实现和后续优化建议 ## Bug修复 - 修复空格键预览无法关闭的问题(事件重复触发) - 统一空格键事件处理逻辑 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
86aa8affc3
commit
cc9764463e
11 files changed
+5190
-79
No files matched your search
@@ -0,0 +1,1671 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Argus Photo Manager - Light Design</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'inter': ['Inter', 'system-ui', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
|
||||
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
.pane-splitter {
|
||||
user-select: none;
|
||||
}
|
||||
.pane-splitter.horizontal {
|
||||
cursor: col-resize;
|
||||
}
|
||||
.pane-splitter.vertical {
|
||||
cursor: row-resize;
|
||||
}
|
||||
.pane-splitter:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
.pane-splitter.dragging {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
.breadcrumb-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.breadcrumb-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.folder-item, .photo-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.folder-item:hover, .photo-item:hover {
|
||||
background-color: #f9fafb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.folder-item:active, .photo-item:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.grid-small {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
}
|
||||
.grid-medium {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
.grid-large {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
.list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.list-item:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
.breadcrumb-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
min-width: 200px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
.breadcrumb-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
.breadcrumb-dropdown-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.breadcrumb-dropdown-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.breadcrumb-separator {
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.breadcrumb-separator:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.path-input-container {
|
||||
position: relative;
|
||||
}
|
||||
.path-input {
|
||||
display: none;
|
||||
min-width: 300px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
.path-input.active {
|
||||
display: block;
|
||||
}
|
||||
.breadcrumb-area {
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.breadcrumb-area.editing {
|
||||
background-color: white;
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
.path-edit-trigger {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.path-edit-trigger:hover {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
.path-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
.path-suggestions.show {
|
||||
display: block;
|
||||
}
|
||||
.path-suggestion-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 13px;
|
||||
}
|
||||
.path-suggestion-item:hover,
|
||||
.path-suggestion-item.selected {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.edit-path-hint {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.item-selected {
|
||||
background-color: #dbeafe !important;
|
||||
border: 2px solid #3b82f6 !important;
|
||||
box-shadow: 0 0 0 1px #3b82f6;
|
||||
}
|
||||
.item-focused {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.selectable-item {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
.selectable-item:focus {
|
||||
outline: none;
|
||||
}
|
||||
.selection-rectangle {
|
||||
position: absolute;
|
||||
border: 2px solid #3b82f6;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.content-area {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 font-inter">
|
||||
<div class="h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="bg-white border-b border-gray-200 px-4 py-3">
|
||||
<!-- File Operations Toolbar -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" title="New Folder">
|
||||
<ion-icon name="add-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm font-medium">New</span>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-300"></div>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Copy">
|
||||
<ion-icon name="copy-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Copy</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Cut">
|
||||
<ion-icon name="cut-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Cut</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Paste" disabled>
|
||||
<ion-icon name="clipboard-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Paste</span>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-300"></div>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Rename">
|
||||
<ion-icon name="create-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Rename</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Delete">
|
||||
<ion-icon name="trash-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Delete</span>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-300"></div>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors" title="Add to Album">
|
||||
<ion-icon name="albums-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Add to Album</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition-colors" title="Edit Tags">
|
||||
<ion-icon name="pricetag-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Tags</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors" title="Batch Edit">
|
||||
<ion-icon name="layers-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Batch Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- View Controls -->
|
||||
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
|
||||
<button id="gridViewBtn" class="px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700" onclick="setViewMode('grid')">Grid</button>
|
||||
<button id="listViewBtn" class="px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700" onclick="setViewMode('list')">List</button>
|
||||
</div>
|
||||
<!-- Thumbnail Size (only show when Grid is selected) -->
|
||||
<div id="thumbnailSizeControls" class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-600">Size:</span>
|
||||
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<button id="smallBtn" class="p-1 rounded hover:bg-white transition-colors" title="Small" onclick="setThumbnailSize('small')">
|
||||
<ion-icon name="apps-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
<button id="mediumBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Medium" onclick="setThumbnailSize('medium')">
|
||||
<ion-icon name="grid-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
<button id="largeBtn" class="p-1 rounded hover:bg-white transition-colors" title="Large" onclick="setThumbnailSize('large')">
|
||||
<ion-icon name="square-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Layout Toggle -->
|
||||
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<button id="horizontalLayoutBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Horizontal Layout" onclick="setLayoutMode('horizontal')">
|
||||
<ion-icon name="resize-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
<button id="verticalLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Vertical Layout" onclick="setLayoutMode('vertical')">
|
||||
<ion-icon name="swap-vertical-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
<button id="singleLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Single Pane" onclick="setLayoutMode('single')">
|
||||
<ion-icon name="square-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div id="mainContent" class="flex-1 flex overflow-hidden">
|
||||
<!-- Left Pane -->
|
||||
<div id="leftPane" class="flex-1 flex flex-col bg-white border-r border-gray-200">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="leftBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="leftBreadcrumbArea">
|
||||
<div id="leftBreadcrumbContent">
|
||||
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-edit-trigger ml-2" onclick="enterEditMode('left')" title="Edit path">
|
||||
<ion-icon name="create-outline" class="text-gray-500 text-sm"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-input-container">
|
||||
<input type="text" id="leftPathInput" class="path-input px-3 py-2 text-sm border border-gray-300 rounded-lg" placeholder="Enter path..." onblur="exitEditMode('left')" onkeydown="handlePathInputKeydown(event, 'left')" oninput="showPathSuggestions(event, 'left')">
|
||||
<div class="path-suggestions" id="leftPathSuggestions">
|
||||
<!-- Suggestions will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 p-4 overflow-auto content-area" id="leftContentArea" onmousedown="startDragSelection(event, 'left')" onmouseup="endDragSelection(event, 'left')" onmousemove="updateDragSelection(event, 'left')" onclick="handleContentAreaClick(event, 'left')">
|
||||
<div id="leftContent" class="grid grid-medium gap-4">
|
||||
<!-- Content will be loaded here -->
|
||||
</div>
|
||||
<div id="leftSelectionRect" class="selection-rectangle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Splitter -->
|
||||
<div id="splitter" class="w-1 bg-gray-200 pane-splitter horizontal hidden"></div>
|
||||
|
||||
<!-- Right Pane (hidden by default) -->
|
||||
<div id="rightPane" class="flex-1 flex-col bg-white hidden">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="rightBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="rightBreadcrumbArea">
|
||||
<div id="rightBreadcrumbContent">
|
||||
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-edit-trigger ml-2" onclick="enterEditMode('right')" title="Edit path">
|
||||
<ion-icon name="create-outline" class="text-gray-500 text-sm"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-input-container">
|
||||
<input type="text" id="rightPathInput" class="path-input px-3 py-2 text-sm border border-gray-300 rounded-lg" placeholder="Enter path..." onblur="exitEditMode('right')" onkeydown="handlePathInputKeydown(event, 'right')" oninput="showPathSuggestions(event, 'right')">
|
||||
<div class="path-suggestions" id="rightPathSuggestions">
|
||||
<!-- Suggestions will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 p-4 overflow-auto content-area" id="rightContentArea" onmousedown="startDragSelection(event, 'right')" onmouseup="endDragSelection(event, 'right')" onmousemove="updateDragSelection(event, 'right')" onclick="handleContentAreaClick(event, 'right')">
|
||||
<div id="rightContent" class="grid grid-medium gap-4">
|
||||
<!-- Content will be loaded here -->
|
||||
</div>
|
||||
<div id="rightSelectionRect" class="selection-rectangle"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Sample data structure
|
||||
const folderStructure = {
|
||||
'Home': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'My Photos': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Vacation 2023': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Beach': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'photo1.jpg': { type: 'photo', size: '2.4 MB', date: '2023-07-15' },
|
||||
'photo2.jpg': { type: 'photo', size: '3.1 MB', date: '2023-07-15' },
|
||||
'photo3.jpg': { type: 'photo', size: '2.8 MB', date: '2023-07-16' },
|
||||
'photo4.jpg': { type: 'photo', size: '4.1 MB', date: '2023-07-16' },
|
||||
'photo5.jpg': { type: 'photo', size: '3.5 MB', date: '2023-07-17' },
|
||||
'beach_sunset.jpg': { type: 'photo', size: '5.2 MB', date: '2023-07-17' },
|
||||
'waves.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-18' },
|
||||
'seashells.jpg': { type: 'photo', size: '2.1 MB', date: '2023-07-18' }
|
||||
}
|
||||
},
|
||||
'Mountains': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'sunrise.jpg': { type: 'photo', size: '4.2 MB', date: '2023-07-20' },
|
||||
'sunset.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-20' },
|
||||
'peak_view.jpg': { type: 'photo', size: '6.1 MB', date: '2023-07-21' },
|
||||
'hiking_trail.jpg': { type: 'photo', size: '4.5 MB', date: '2023-07-21' },
|
||||
'mountain_lake.jpg': { type: 'photo', size: '5.3 MB', date: '2023-07-22' },
|
||||
'forest.jpg': { type: 'photo', size: '4.8 MB', date: '2023-07-22' }
|
||||
}
|
||||
},
|
||||
'City Tour': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'architecture.jpg': { type: 'photo', size: '3.2 MB', date: '2023-07-25' },
|
||||
'street_art.jpg': { type: 'photo', size: '2.7 MB', date: '2023-07-25' },
|
||||
'museum.jpg': { type: 'photo', size: '2.9 MB', date: '2023-07-26' },
|
||||
'cafe.jpg': { type: 'photo', size: '2.3 MB', date: '2023-07-26' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'Family': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Birthday Party': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'cake.jpg': { type: 'photo', size: '2.1 MB', date: '2023-08-10' },
|
||||
'group_photo.jpg': { type: 'photo', size: '3.4 MB', date: '2023-08-10' },
|
||||
'presents.jpg': { type: 'photo', size: '2.8 MB', date: '2023-08-10' }
|
||||
}
|
||||
},
|
||||
'dinner.jpg': { type: 'photo', size: '2.2 MB', date: '2023-08-15' },
|
||||
'family_portrait.jpg': { type: 'photo', size: '4.1 MB', date: '2023-08-20' },
|
||||
'kids_playing.jpg': { type: 'photo', size: '3.3 MB', date: '2023-08-22' },
|
||||
'garden.jpg': { type: 'photo', size: '2.9 MB', date: '2023-08-25' }
|
||||
}
|
||||
},
|
||||
'Events': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'wedding.jpg': { type: 'photo', size: '5.1 MB', date: '2023-09-05' },
|
||||
'graduation.jpg': { type: 'photo', size: '3.7 MB', date: '2023-09-10' },
|
||||
'concert.jpg': { type: 'photo', size: '2.8 MB', date: '2023-09-15' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'Work Projects': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Screenshots': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'design1.png': { type: 'photo', size: '1.2 MB', date: '2023-09-01' },
|
||||
'design2.png': { type: 'photo', size: '1.5 MB', date: '2023-09-02' },
|
||||
'mockup.png': { type: 'photo', size: '2.1 MB', date: '2023-09-03' },
|
||||
'wireframe.png': { type: 'photo', size: '0.8 MB', date: '2023-09-04' },
|
||||
'prototype.png': { type: 'photo', size: '1.9 MB', date: '2023-09-05' }
|
||||
}
|
||||
},
|
||||
'Presentations': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'slide1.jpg': { type: 'photo', size: '1.3 MB', date: '2023-09-08' },
|
||||
'slide2.jpg': { type: 'photo', size: '1.1 MB', date: '2023-09-08' },
|
||||
'slide3.jpg': { type: 'photo', size: '1.4 MB', date: '2023-09-08' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'Camera Roll': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Recent': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'IMG_010.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-25' },
|
||||
'IMG_011.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-25' },
|
||||
'IMG_012.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-26' },
|
||||
'IMG_013.jpg': { type: 'photo', size: '4.1 MB', date: '2023-09-26' }
|
||||
}
|
||||
},
|
||||
'IMG_001.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-15' },
|
||||
'IMG_002.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-15' },
|
||||
'IMG_003.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-16' },
|
||||
'IMG_004.jpg': { type: 'photo', size: '2.7 MB', date: '2023-09-17' },
|
||||
'IMG_005.jpg': { type: 'photo', size: '3.8 MB', date: '2023-09-18' },
|
||||
'IMG_006.jpg': { type: 'photo', size: '4.2 MB', date: '2023-09-19' },
|
||||
'IMG_007.jpg': { type: 'photo', size: '3.1 MB', date: '2023-09-20' },
|
||||
'IMG_008.jpg': { type: 'photo', size: '2.8 MB', date: '2023-09-21' },
|
||||
'IMG_009.jpg': { type: 'photo', size: '3.6 MB', date: '2023-09-22' }
|
||||
}
|
||||
},
|
||||
'Archives': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'2022': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'old_photo1.jpg': { type: 'photo', size: '2.1 MB', date: '2022-12-25' },
|
||||
'old_photo2.jpg': { type: 'photo', size: '1.8 MB', date: '2022-12-31' }
|
||||
}
|
||||
},
|
||||
'2021': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'vintage1.jpg': { type: 'photo', size: '1.5 MB', date: '2021-06-15' },
|
||||
'vintage2.jpg': { type: 'photo', size: '1.9 MB', date: '2021-08-20' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Application state
|
||||
let layoutMode = 'single'; // 'single', 'horizontal', 'vertical'
|
||||
let viewMode = 'grid'; // 'grid', 'list'
|
||||
let thumbnailSize = 'medium'; // 'small', 'medium', 'large'
|
||||
let leftPath = ['Home'];
|
||||
let rightPath = ['Home'];
|
||||
let isDragging = false;
|
||||
|
||||
// Selection state
|
||||
let leftSelectedItems = new Set();
|
||||
let rightSelectedItems = new Set();
|
||||
let leftFocusedItem = null;
|
||||
let rightFocusedItem = null;
|
||||
let leftAnchorItem = null; // For shift selection
|
||||
let rightAnchorItem = null; // For shift selection
|
||||
|
||||
// Drag selection state
|
||||
let isDragSelecting = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let dragCurrentX = 0;
|
||||
let dragCurrentY = 0;
|
||||
let dragPane = null;
|
||||
let dragInitialSelections = new Set(); // Store selections before drag starts
|
||||
let dragCtrlKey = false; // Store ctrl key state during drag
|
||||
let justFinishedDrag = false; // Prevent click event from clearing selection after drag
|
||||
|
||||
// Initialize the application
|
||||
function init() {
|
||||
loadContent('left');
|
||||
updateBreadcrumb('left');
|
||||
setupSplitter();
|
||||
setupKeyboardNavigation();
|
||||
}
|
||||
|
||||
// Get current folder from path
|
||||
function getCurrentFolder(path) {
|
||||
let current = folderStructure;
|
||||
for (let segment of path) {
|
||||
if (current[segment] && current[segment].children) {
|
||||
current = current[segment].children;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
// Load content for a pane
|
||||
function loadContent(pane) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const contentElement = document.getElementById(pane + 'Content');
|
||||
const currentFolder = getCurrentFolder(path);
|
||||
|
||||
// Clear selection when navigating
|
||||
if (pane === 'left') {
|
||||
leftSelectedItems.clear();
|
||||
leftFocusedItem = null;
|
||||
leftAnchorItem = null;
|
||||
} else {
|
||||
rightSelectedItems.clear();
|
||||
rightFocusedItem = null;
|
||||
rightAnchorItem = null;
|
||||
}
|
||||
|
||||
if (!currentFolder) {
|
||||
contentElement.innerHTML = '<p class="text-gray-500">Folder not found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort items: folders first, then photos
|
||||
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (viewMode === 'list') {
|
||||
contentElement.className = 'list-view';
|
||||
let html = '';
|
||||
|
||||
items.forEach(([name, item], index) => {
|
||||
const itemId = `${pane}-item-${index}`;
|
||||
if (item.type === 'folder') {
|
||||
html += `
|
||||
<div class="list-item folder-item selectable-item" data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
|
||||
onclick="handleItemClick(event, '${pane}', '${name}', ${index})"
|
||||
ondblclick="navigateToFolder('${pane}', '${name}')">
|
||||
<ion-icon name="folder-outline" class="text-blue-500 text-lg mr-3"></ion-icon>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-700">${name}</p>
|
||||
<p class="text-xs text-gray-500">Folder</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (item.type === 'photo') {
|
||||
html += `
|
||||
<div class="list-item photo-item selectable-item" data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
|
||||
onclick="handleItemClick(event, '${pane}', '${name}', ${index})">
|
||||
<ion-icon name="image-outline" class="text-green-500 text-lg mr-3"></ion-icon>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-700">${name}</p>
|
||||
<p class="text-xs text-gray-500">${item.size} • ${item.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
contentElement.innerHTML = html;
|
||||
} else {
|
||||
// Grid view
|
||||
contentElement.className = `grid grid-${thumbnailSize} gap-4`;
|
||||
let html = '';
|
||||
|
||||
items.forEach(([name, item], index) => {
|
||||
const itemId = `${pane}-item-${index}`;
|
||||
if (item.type === 'folder') {
|
||||
html += `
|
||||
<div class="folder-item selectable-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all"
|
||||
data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
|
||||
onclick="handleItemClick(event, '${pane}', '${name}', ${index})"
|
||||
ondblclick="navigateToFolder('${pane}', '${name}')">
|
||||
<ion-icon name="folder-outline" class="text-blue-500 text-3xl mb-2"></ion-icon>
|
||||
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
|
||||
</div>
|
||||
`;
|
||||
} else if (item.type === 'photo') {
|
||||
html += `
|
||||
<div class="photo-item selectable-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all"
|
||||
data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
|
||||
onclick="handleItemClick(event, '${pane}', '${name}', ${index})">
|
||||
<ion-icon name="image-outline" class="text-green-500 text-3xl mb-2"></ion-icon>
|
||||
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
|
||||
<p class="text-xs text-gray-500">${item.size}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
contentElement.innerHTML = html;
|
||||
}
|
||||
|
||||
// Set default focus to first item (but don't select it)
|
||||
if (items.length > 0) {
|
||||
if (pane === 'left') {
|
||||
leftFocusedItem = { name: items[0][0], index: 0 };
|
||||
leftAnchorItem = { name: items[0][0], index: 0 };
|
||||
} else {
|
||||
rightFocusedItem = { name: items[0][0], index: 0 };
|
||||
rightAnchorItem = { name: items[0][0], index: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to folder
|
||||
function navigateToFolder(pane, folderName) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
path.push(folderName);
|
||||
|
||||
loadContent(pane);
|
||||
updateBreadcrumb(pane);
|
||||
}
|
||||
|
||||
// Update breadcrumb navigation
|
||||
function updateBreadcrumb(pane) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const breadcrumbContentElement = document.getElementById(pane + 'BreadcrumbContent');
|
||||
|
||||
let html = '<div class="flex items-center space-x-1">';
|
||||
|
||||
path.forEach((segment, index) => {
|
||||
if (index > 0) {
|
||||
html += `
|
||||
<div class="breadcrumb-separator" onclick="showBreadcrumbDropdown('${pane}', ${index - 1})">
|
||||
<ion-icon name="chevron-forward-outline" class="text-gray-400 text-xs"></ion-icon>
|
||||
<div class="breadcrumb-dropdown" id="${pane}Dropdown${index - 1}">
|
||||
<!-- Dropdown content will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (index === path.length - 1) {
|
||||
html += `<span class="px-2 py-1 rounded bg-blue-100 text-blue-700 font-medium">${segment}</span>`;
|
||||
} else {
|
||||
html += `<span class="breadcrumb-item px-2 py-1 rounded text-gray-600 hover:bg-gray-100" onclick="navigateToBreadcrumb('${pane}', ${index})">${segment}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
breadcrumbContentElement.innerHTML = html;
|
||||
}
|
||||
|
||||
// Navigate via breadcrumb
|
||||
function navigateToBreadcrumb(pane, index) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
|
||||
if (pane === 'left') {
|
||||
leftPath = leftPath.slice(0, index + 1);
|
||||
} else {
|
||||
rightPath = rightPath.slice(0, index + 1);
|
||||
}
|
||||
|
||||
loadContent(pane);
|
||||
updateBreadcrumb(pane);
|
||||
}
|
||||
|
||||
// Set view mode (grid/list)
|
||||
function setViewMode(mode) {
|
||||
viewMode = mode;
|
||||
|
||||
// Update button states
|
||||
document.getElementById('gridViewBtn').className = mode === 'grid'
|
||||
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
|
||||
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
|
||||
|
||||
document.getElementById('listViewBtn').className = mode === 'list'
|
||||
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
|
||||
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
|
||||
|
||||
// Show/hide thumbnail size controls based on view mode
|
||||
const thumbnailControls = document.getElementById('thumbnailSizeControls');
|
||||
if (mode === 'grid') {
|
||||
thumbnailControls.style.display = 'flex';
|
||||
} else {
|
||||
thumbnailControls.style.display = 'none';
|
||||
}
|
||||
|
||||
// Reload content
|
||||
loadContent('left');
|
||||
if (layoutMode !== 'single') {
|
||||
loadContent('right');
|
||||
}
|
||||
}
|
||||
|
||||
// Set thumbnail size
|
||||
function setThumbnailSize(size) {
|
||||
thumbnailSize = size;
|
||||
|
||||
// Update button states
|
||||
['small', 'medium', 'large'].forEach(s => {
|
||||
document.getElementById(s + 'Btn').className = s === size
|
||||
? 'p-1 rounded bg-white shadow-sm transition-colors'
|
||||
: 'p-1 rounded hover:bg-white transition-colors';
|
||||
});
|
||||
|
||||
// Reload content if in grid mode
|
||||
if (viewMode === 'grid') {
|
||||
loadContent('left');
|
||||
if (layoutMode !== 'single') {
|
||||
loadContent('right');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set layout mode
|
||||
function setLayoutMode(mode) {
|
||||
layoutMode = mode;
|
||||
|
||||
const rightPane = document.getElementById('rightPane');
|
||||
const splitter = document.getElementById('splitter');
|
||||
const mainContent = document.getElementById('mainContent');
|
||||
|
||||
// Update button states
|
||||
['horizontal', 'vertical', 'single'].forEach(m => {
|
||||
const btn = document.getElementById(m + 'LayoutBtn');
|
||||
btn.className = m === mode
|
||||
? 'p-1 rounded bg-white shadow-sm transition-colors'
|
||||
: 'p-1 rounded hover:bg-white transition-colors';
|
||||
});
|
||||
|
||||
if (mode === 'single') {
|
||||
rightPane.classList.add('hidden');
|
||||
rightPane.classList.remove('flex');
|
||||
splitter.classList.add('hidden');
|
||||
mainContent.style.flexDirection = '';
|
||||
mainContent.style.gridTemplateColumns = '';
|
||||
mainContent.style.gridTemplateRows = '';
|
||||
} else if (mode === 'horizontal') {
|
||||
rightPane.classList.remove('hidden');
|
||||
rightPane.classList.add('flex');
|
||||
splitter.classList.remove('hidden');
|
||||
splitter.className = 'w-1 bg-gray-200 pane-splitter horizontal';
|
||||
mainContent.style.flexDirection = 'row';
|
||||
mainContent.style.gridTemplateColumns = '1fr 4px 1fr';
|
||||
mainContent.style.gridTemplateRows = '';
|
||||
mainContent.style.display = 'grid';
|
||||
loadContent('right');
|
||||
updateBreadcrumb('right');
|
||||
} else if (mode === 'vertical') {
|
||||
rightPane.classList.remove('hidden');
|
||||
rightPane.classList.add('flex');
|
||||
splitter.classList.remove('hidden');
|
||||
splitter.className = 'h-1 bg-gray-200 pane-splitter vertical';
|
||||
mainContent.style.flexDirection = 'column';
|
||||
mainContent.style.gridTemplateRows = '1fr 4px 1fr';
|
||||
mainContent.style.gridTemplateColumns = '';
|
||||
mainContent.style.display = 'grid';
|
||||
loadContent('right');
|
||||
updateBreadcrumb('right');
|
||||
}
|
||||
}
|
||||
|
||||
// Setup splitter functionality
|
||||
function setupSplitter() {
|
||||
const splitter = document.getElementById('splitter');
|
||||
const mainContent = document.getElementById('mainContent');
|
||||
|
||||
splitter.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
splitter.classList.add('dragging');
|
||||
|
||||
if (layoutMode === 'horizontal') {
|
||||
document.body.style.cursor = 'col-resize';
|
||||
} else if (layoutMode === 'vertical') {
|
||||
document.body.style.cursor = 'row-resize';
|
||||
}
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const rect = mainContent.getBoundingClientRect();
|
||||
|
||||
if (layoutMode === 'horizontal') {
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = (x / rect.width) * 100;
|
||||
|
||||
if (percentage > 20 && percentage < 80) {
|
||||
mainContent.style.gridTemplateColumns = `${percentage}% 4px 1fr`;
|
||||
}
|
||||
} else if (layoutMode === 'vertical') {
|
||||
const y = e.clientY - rect.top;
|
||||
const percentage = (y / rect.height) * 100;
|
||||
|
||||
if (percentage > 20 && percentage < 80) {
|
||||
mainContent.style.gridTemplateRows = `${percentage}% 4px 1fr`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
splitter.classList.remove('dragging');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show breadcrumb dropdown
|
||||
function showBreadcrumbDropdown(pane, index) {
|
||||
// Hide all other dropdowns
|
||||
document.querySelectorAll('.breadcrumb-dropdown').forEach(dropdown => {
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const dropdownId = `${pane}Dropdown${index}`;
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
|
||||
if (!dropdown) return;
|
||||
|
||||
// Get folders at the specified path
|
||||
const currentPath = path.slice(0, index + 1);
|
||||
const currentFolder = getCurrentFolder(currentPath);
|
||||
|
||||
if (!currentFolder) return;
|
||||
|
||||
// Build dropdown content
|
||||
let html = '';
|
||||
Object.entries(currentFolder).forEach(([name, item]) => {
|
||||
if (item.type === 'folder') {
|
||||
html += `
|
||||
<div class="breadcrumb-dropdown-item" onclick="navigateFromBreadcrumbDropdown('${pane}', ${index}, '${name}')">
|
||||
<ion-icon name="folder-outline" class="text-blue-500 text-sm mr-2"></ion-icon>
|
||||
<span class="text-sm">${name}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
if (html === '') {
|
||||
html = '<div class="breadcrumb-dropdown-item"><span class="text-sm text-gray-500">No folders</span></div>';
|
||||
}
|
||||
|
||||
dropdown.innerHTML = html;
|
||||
dropdown.classList.add('show');
|
||||
|
||||
// Hide dropdown when clicking outside
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', function hideDropdown(e) {
|
||||
if (!dropdown.contains(e.target) && !e.target.closest('.breadcrumb-separator')) {
|
||||
dropdown.classList.remove('show');
|
||||
document.removeEventListener('click', hideDropdown);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Navigate from breadcrumb dropdown
|
||||
function navigateFromBreadcrumbDropdown(pane, index, folderName) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const newPath = path.slice(0, index + 1);
|
||||
newPath.push(folderName);
|
||||
|
||||
if (pane === 'left') {
|
||||
leftPath = newPath;
|
||||
} else {
|
||||
rightPath = newPath;
|
||||
}
|
||||
|
||||
loadContent(pane);
|
||||
updateBreadcrumb(pane);
|
||||
|
||||
// Hide dropdown immediately after selection
|
||||
const dropdown = document.getElementById(`${pane}Dropdown${index}`);
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
// Enter edit mode (address bar style)
|
||||
function enterEditMode(pane) {
|
||||
const breadcrumbArea = document.getElementById(pane + 'BreadcrumbArea');
|
||||
const editTrigger = breadcrumbArea.parentElement.querySelector('.path-edit-trigger');
|
||||
const inputElement = document.getElementById(pane + 'PathInput');
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
|
||||
breadcrumbArea.style.display = 'none';
|
||||
editTrigger.style.display = 'none';
|
||||
inputElement.classList.add('active');
|
||||
inputElement.value = path.join('/');
|
||||
inputElement.focus();
|
||||
inputElement.select();
|
||||
}
|
||||
|
||||
// Exit edit mode
|
||||
function exitEditMode(pane) {
|
||||
const breadcrumbArea = document.getElementById(pane + 'BreadcrumbArea');
|
||||
const editTrigger = breadcrumbArea.parentElement.querySelector('.path-edit-trigger');
|
||||
const inputElement = document.getElementById(pane + 'PathInput');
|
||||
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
|
||||
|
||||
breadcrumbArea.style.display = 'flex';
|
||||
editTrigger.style.display = 'block';
|
||||
inputElement.classList.remove('active');
|
||||
suggestionsElement.classList.remove('show');
|
||||
}
|
||||
|
||||
// Handle path input keydown
|
||||
function handlePathInputKeydown(event, pane) {
|
||||
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
|
||||
const suggestions = suggestionsElement.querySelectorAll('.path-suggestion-item');
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
const inputElement = document.getElementById(pane + 'PathInput');
|
||||
const pathString = inputElement.value.trim();
|
||||
|
||||
if (pathString) {
|
||||
navigateToPath(pane, pathString);
|
||||
}
|
||||
|
||||
exitEditMode(pane);
|
||||
} else if (event.key === 'Escape') {
|
||||
exitEditMode(pane);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const selected = suggestionsElement.querySelector('.selected');
|
||||
if (selected && selected.nextElementSibling) {
|
||||
selected.classList.remove('selected');
|
||||
selected.nextElementSibling.classList.add('selected');
|
||||
} else if (suggestions.length > 0) {
|
||||
suggestions.forEach(s => s.classList.remove('selected'));
|
||||
suggestions[0].classList.add('selected');
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const selected = suggestionsElement.querySelector('.selected');
|
||||
if (selected && selected.previousElementSibling) {
|
||||
selected.classList.remove('selected');
|
||||
selected.previousElementSibling.classList.add('selected');
|
||||
} else if (suggestions.length > 0) {
|
||||
suggestions.forEach(s => s.classList.remove('selected'));
|
||||
suggestions[suggestions.length - 1].classList.add('selected');
|
||||
}
|
||||
} else if (event.key === 'Tab' && suggestions.length > 0) {
|
||||
event.preventDefault();
|
||||
const selected = suggestionsElement.querySelector('.selected') || suggestions[0];
|
||||
if (selected) {
|
||||
document.getElementById(pane + 'PathInput').value = selected.dataset.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show path suggestions
|
||||
function showPathSuggestions(event, pane) {
|
||||
const inputValue = event.target.value.trim();
|
||||
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
|
||||
|
||||
if (!inputValue) {
|
||||
suggestionsElement.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all possible paths
|
||||
const allPaths = getAllPossiblePaths();
|
||||
const filteredPaths = allPaths.filter(path =>
|
||||
path.toLowerCase().includes(inputValue.toLowerCase()) && path !== inputValue
|
||||
);
|
||||
|
||||
if (filteredPaths.length === 0) {
|
||||
suggestionsElement.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
filteredPaths.slice(0, 8).forEach((path, index) => {
|
||||
html += `
|
||||
<div class="path-suggestion-item ${index === 0 ? 'selected' : ''}"
|
||||
data-path="${path}"
|
||||
onclick="selectPathSuggestion('${pane}', '${path}')">
|
||||
<ion-icon name="folder-outline" class="text-blue-500 text-sm mr-2"></ion-icon>
|
||||
<span>${path}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
suggestionsElement.innerHTML = html;
|
||||
suggestionsElement.classList.add('show');
|
||||
}
|
||||
|
||||
// Get all possible paths
|
||||
function getAllPossiblePaths() {
|
||||
const paths = [];
|
||||
|
||||
function traverseFolder(currentPath, folder) {
|
||||
Object.entries(folder).forEach(([name, item]) => {
|
||||
if (item.type === 'folder') {
|
||||
const newPath = currentPath ? `${currentPath}/${name}` : name;
|
||||
paths.push(newPath);
|
||||
if (item.children) {
|
||||
traverseFolder(newPath, item.children);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
traverseFolder('', folderStructure);
|
||||
return paths.sort();
|
||||
}
|
||||
|
||||
// Select path suggestion
|
||||
function selectPathSuggestion(pane, path) {
|
||||
document.getElementById(pane + 'PathInput').value = path;
|
||||
navigateToPath(pane, path);
|
||||
exitEditMode(pane);
|
||||
}
|
||||
|
||||
// Navigate to path
|
||||
function navigateToPath(pane, pathString) {
|
||||
const pathSegments = pathString.split('/').filter(segment => segment.trim() !== '');
|
||||
|
||||
// Always start from Home
|
||||
if (pathSegments.length === 0 || pathSegments[0] !== 'Home') {
|
||||
pathSegments.unshift('Home');
|
||||
}
|
||||
|
||||
// Validate path exists
|
||||
let current = folderStructure;
|
||||
for (let i = 0; i < pathSegments.length; i++) {
|
||||
const segment = pathSegments[i];
|
||||
if (current[segment] && current[segment].children) {
|
||||
current = current[segment].children;
|
||||
} else if (i === pathSegments.length - 1 && current[segment]) {
|
||||
// Last segment can be a file or folder
|
||||
break;
|
||||
} else {
|
||||
// Invalid path
|
||||
alert('Path not found: ' + pathSegments.slice(0, i + 1).join('/'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set new path
|
||||
if (pane === 'left') {
|
||||
leftPath = pathSegments;
|
||||
} else {
|
||||
rightPath = pathSegments;
|
||||
}
|
||||
|
||||
loadContent(pane);
|
||||
updateBreadcrumb(pane);
|
||||
}
|
||||
|
||||
// Handle item click for selection
|
||||
function handleItemClick(event, pane, itemName, index) {
|
||||
event.stopPropagation();
|
||||
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
const items = getVisibleItems(pane);
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Range selection with Shift
|
||||
const anchorItem = pane === 'left' ? leftAnchorItem : rightAnchorItem;
|
||||
|
||||
if (anchorItem !== null) {
|
||||
// Clear previous selections if not holding Ctrl
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
clearAllSelections(pane);
|
||||
}
|
||||
|
||||
// Select range from anchor to current
|
||||
const startIndex = Math.min(anchorItem.index, index);
|
||||
const endIndex = Math.max(anchorItem.index, index);
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (items[i]) {
|
||||
selectedItems.add(items[i].name);
|
||||
addItemSelection(pane, items[i].name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No anchor, treat as normal click
|
||||
clearAllSelections(pane);
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
|
||||
// Set anchor
|
||||
if (pane === 'left') {
|
||||
leftAnchorItem = { name: itemName, index: index };
|
||||
} else {
|
||||
rightAnchorItem = { name: itemName, index: index };
|
||||
}
|
||||
}
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Toggle selection with Ctrl/Cmd
|
||||
if (selectedItems.has(itemName)) {
|
||||
selectedItems.delete(itemName);
|
||||
removeItemSelection(pane, itemName);
|
||||
} else {
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
}
|
||||
|
||||
// Update anchor
|
||||
if (pane === 'left') {
|
||||
leftAnchorItem = { name: itemName, index: index };
|
||||
} else {
|
||||
rightAnchorItem = { name: itemName, index: index };
|
||||
}
|
||||
} else {
|
||||
// Single select - clear all previous selections
|
||||
clearAllSelections(pane);
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
|
||||
// Set anchor
|
||||
if (pane === 'left') {
|
||||
leftAnchorItem = { name: itemName, index: index };
|
||||
} else {
|
||||
rightAnchorItem = { name: itemName, index: index };
|
||||
}
|
||||
}
|
||||
|
||||
setFocusedItem(pane, itemName, index);
|
||||
}
|
||||
|
||||
// Set focused item
|
||||
function setFocusedItem(pane, itemName, index) {
|
||||
// Clear previous focus in the specific pane
|
||||
const contentArea = document.getElementById(pane + 'ContentArea');
|
||||
if (contentArea) {
|
||||
contentArea.querySelectorAll('.item-focused').forEach(el => {
|
||||
el.classList.remove('item-focused');
|
||||
});
|
||||
}
|
||||
|
||||
if (pane === 'left') {
|
||||
leftFocusedItem = { name: itemName, index: index };
|
||||
} else {
|
||||
rightFocusedItem = { name: itemName, index: index };
|
||||
}
|
||||
|
||||
// Add focus to new item
|
||||
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
|
||||
if (itemElement) {
|
||||
itemElement.classList.add('item-focused');
|
||||
itemElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Add visual selection to item
|
||||
function addItemSelection(pane, itemName) {
|
||||
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
|
||||
if (itemElement) {
|
||||
itemElement.classList.add('item-selected');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove visual selection from item
|
||||
function removeItemSelection(pane, itemName) {
|
||||
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
|
||||
if (itemElement) {
|
||||
itemElement.classList.remove('item-selected');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all selections in a pane
|
||||
function clearAllSelections(pane) {
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
selectedItems.forEach(itemName => {
|
||||
removeItemSelection(pane, itemName);
|
||||
});
|
||||
selectedItems.clear();
|
||||
}
|
||||
|
||||
// Setup keyboard navigation
|
||||
function setupKeyboardNavigation() {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const activePane = getActivePane();
|
||||
if (!activePane) return;
|
||||
|
||||
// Don't interfere with path input
|
||||
if (event.target.tagName === 'INPUT') return;
|
||||
|
||||
const items = getVisibleItems(activePane);
|
||||
if (items.length === 0) return;
|
||||
|
||||
const focusedItem = activePane === 'left' ? leftFocusedItem : rightFocusedItem;
|
||||
const selectedItems = activePane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
|
||||
// If no focused item, focus first item
|
||||
if (!focusedItem && items.length > 0) {
|
||||
setFocusedItem(activePane, items[0].name, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!focusedItem) return;
|
||||
|
||||
let newIndex = focusedItem.index;
|
||||
let shouldUpdateFocus = false;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (viewMode === 'list') {
|
||||
newIndex = Math.max(0, focusedItem.index - 1);
|
||||
} else {
|
||||
// Grid view - move up by number of columns
|
||||
const cols = getGridColumns();
|
||||
newIndex = Math.max(0, focusedItem.index - cols);
|
||||
}
|
||||
shouldUpdateFocus = true;
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (viewMode === 'list') {
|
||||
newIndex = Math.min(items.length - 1, focusedItem.index + 1);
|
||||
} else {
|
||||
// Grid view - move down by number of columns
|
||||
const cols = getGridColumns();
|
||||
newIndex = Math.min(items.length - 1, focusedItem.index + cols);
|
||||
}
|
||||
shouldUpdateFocus = true;
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
if (viewMode === 'grid') {
|
||||
event.preventDefault();
|
||||
newIndex = Math.max(0, focusedItem.index - 1);
|
||||
shouldUpdateFocus = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
if (viewMode === 'grid') {
|
||||
event.preventDefault();
|
||||
newIndex = Math.min(items.length - 1, focusedItem.index + 1);
|
||||
shouldUpdateFocus = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
const currentItem = items[focusedItem.index];
|
||||
if (currentItem && currentItem.type === 'folder') {
|
||||
navigateToFolder(activePane, currentItem.name);
|
||||
}
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
// Space toggles selection of focused item
|
||||
const currentItemName = items[focusedItem.index].name;
|
||||
if (selectedItems.has(currentItemName)) {
|
||||
selectedItems.delete(currentItemName);
|
||||
removeItemSelection(activePane, currentItemName);
|
||||
} else {
|
||||
selectedItems.add(currentItemName);
|
||||
addItemSelection(activePane, currentItemName);
|
||||
}
|
||||
|
||||
// Update anchor
|
||||
if (activePane === 'left') {
|
||||
leftAnchorItem = { name: currentItemName, index: focusedItem.index };
|
||||
} else {
|
||||
rightAnchorItem = { name: currentItemName, index: focusedItem.index };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'a':
|
||||
// Ctrl+A to select all
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
clearAllSelections(activePane);
|
||||
items.forEach(item => {
|
||||
selectedItems.add(item.name);
|
||||
addItemSelection(activePane, item.name);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle focus and selection changes for arrow keys
|
||||
if (shouldUpdateFocus && newIndex !== focusedItem.index) {
|
||||
const newItem = items[newIndex];
|
||||
if (!newItem) return;
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift+Arrow: extend selection from anchor to new position
|
||||
const anchorItem = activePane === 'left' ? leftAnchorItem : rightAnchorItem;
|
||||
|
||||
if (anchorItem !== null) {
|
||||
// Clear and reselect range
|
||||
clearAllSelections(activePane);
|
||||
|
||||
const startIndex = Math.min(anchorItem.index, newIndex);
|
||||
const endIndex = Math.max(anchorItem.index, newIndex);
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (items[i]) {
|
||||
selectedItems.add(items[i].name);
|
||||
addItemSelection(activePane, items[i].name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No anchor, select from current to new
|
||||
const startIndex = Math.min(focusedItem.index, newIndex);
|
||||
const endIndex = Math.max(focusedItem.index, newIndex);
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (items[i]) {
|
||||
selectedItems.add(items[i].name);
|
||||
addItemSelection(activePane, items[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
// Set anchor to starting position
|
||||
if (activePane === 'left') {
|
||||
leftAnchorItem = { name: focusedItem.name, index: focusedItem.index };
|
||||
} else {
|
||||
rightAnchorItem = { name: focusedItem.name, index: focusedItem.index };
|
||||
}
|
||||
}
|
||||
|
||||
setFocusedItem(activePane, newItem.name, newIndex);
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl+Arrow: move focus only, don't change selection
|
||||
setFocusedItem(activePane, newItem.name, newIndex);
|
||||
} else {
|
||||
// Plain arrow: move focus and select only the new item
|
||||
clearAllSelections(activePane);
|
||||
selectedItems.add(newItem.name);
|
||||
addItemSelection(activePane, newItem.name);
|
||||
setFocusedItem(activePane, newItem.name, newIndex);
|
||||
|
||||
// Update anchor
|
||||
if (activePane === 'left') {
|
||||
leftAnchorItem = { name: newItem.name, index: newIndex };
|
||||
} else {
|
||||
rightAnchorItem = { name: newItem.name, index: newIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get currently active pane
|
||||
function getActivePane() {
|
||||
const leftPane = document.getElementById('leftPane');
|
||||
const rightPane = document.getElementById('rightPane');
|
||||
|
||||
if (document.activeElement && leftPane.contains(document.activeElement)) {
|
||||
return 'left';
|
||||
} else if (document.activeElement && rightPane.contains(document.activeElement)) {
|
||||
return 'right';
|
||||
} else {
|
||||
// Default to left pane
|
||||
return 'left';
|
||||
}
|
||||
}
|
||||
|
||||
// Get visible items in current folder
|
||||
function getVisibleItems(pane) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const currentFolder = getCurrentFolder(path);
|
||||
|
||||
if (!currentFolder) return [];
|
||||
|
||||
// Sort items: folders first, then photos
|
||||
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return items.map(([name, item], index) => ({ name, type: item.type, index }));
|
||||
}
|
||||
|
||||
// Get number of columns in grid view
|
||||
function getGridColumns() {
|
||||
const contentElement = document.getElementById('leftContent');
|
||||
if (!contentElement) return 4;
|
||||
|
||||
const computedStyle = window.getComputedStyle(contentElement);
|
||||
const gridCols = computedStyle.gridTemplateColumns;
|
||||
|
||||
if (gridCols && gridCols !== 'none') {
|
||||
return gridCols.split(' ').length;
|
||||
}
|
||||
|
||||
// Fallback based on thumbnail size
|
||||
switch (thumbnailSize) {
|
||||
case 'small': return 8;
|
||||
case 'medium': return 6;
|
||||
case 'large': return 4;
|
||||
default: return 4;
|
||||
}
|
||||
}
|
||||
|
||||
// Start drag selection
|
||||
function startDragSelection(event, pane) {
|
||||
// Only start drag selection if clicking on empty area (not on an item)
|
||||
if (event.target.closest('.selectable-item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the flag
|
||||
justFinishedDrag = false;
|
||||
|
||||
isDragSelecting = true;
|
||||
dragPane = pane;
|
||||
dragCtrlKey = event.ctrlKey || event.metaKey;
|
||||
|
||||
const contentArea = document.getElementById(pane + 'ContentArea');
|
||||
const rect = contentArea.getBoundingClientRect();
|
||||
|
||||
dragStartX = event.clientX - rect.left + contentArea.scrollLeft;
|
||||
dragStartY = event.clientY - rect.top + contentArea.scrollTop;
|
||||
dragCurrentX = dragStartX;
|
||||
dragCurrentY = dragStartY;
|
||||
|
||||
// Store initial selections when Ctrl is held
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
dragInitialSelections = new Set(selectedItems);
|
||||
|
||||
// Clear previous selections if not holding Ctrl
|
||||
if (!dragCtrlKey) {
|
||||
clearAllSelections(pane);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Update drag selection
|
||||
function updateDragSelection(event, pane) {
|
||||
if (!isDragSelecting || dragPane !== pane) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentArea = document.getElementById(pane + 'ContentArea');
|
||||
const rect = contentArea.getBoundingClientRect();
|
||||
|
||||
dragCurrentX = event.clientX - rect.left + contentArea.scrollLeft;
|
||||
dragCurrentY = event.clientY - rect.top + contentArea.scrollTop;
|
||||
|
||||
// Update selection rectangle
|
||||
updateSelectionRectangle(pane);
|
||||
|
||||
// Check which items intersect with selection rectangle
|
||||
updateItemSelection(pane);
|
||||
}
|
||||
|
||||
// End drag selection
|
||||
function endDragSelection(event, pane) {
|
||||
if (!isDragSelecting || dragPane !== pane) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this was actually a drag (mouse moved significantly)
|
||||
const dragDistance = Math.sqrt(
|
||||
Math.pow(dragCurrentX - dragStartX, 2) +
|
||||
Math.pow(dragCurrentY - dragStartY, 2)
|
||||
);
|
||||
|
||||
// Only treat as drag if moved more than 5 pixels
|
||||
if (dragDistance > 5) {
|
||||
justFinishedDrag = true;
|
||||
|
||||
// Final selection update to ensure all intersecting items are selected
|
||||
updateItemSelection(pane);
|
||||
|
||||
// Set focus to the last selected item
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
if (selectedItems.size > 0) {
|
||||
const items = getVisibleItems(pane);
|
||||
const selectedArray = Array.from(selectedItems);
|
||||
const lastSelected = selectedArray[selectedArray.length - 1];
|
||||
|
||||
// Find index of last selected item
|
||||
const itemIndex = items.findIndex(item => item.name === lastSelected);
|
||||
if (itemIndex !== -1) {
|
||||
setFocusedItem(pane, lastSelected, itemIndex);
|
||||
|
||||
// Set anchor to last selected item
|
||||
if (pane === 'left') {
|
||||
leftAnchorItem = { name: lastSelected, index: itemIndex };
|
||||
} else {
|
||||
rightAnchorItem = { name: lastSelected, index: itemIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the flag after a short delay to allow click event to check it
|
||||
setTimeout(() => {
|
||||
justFinishedDrag = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
isDragSelecting = false;
|
||||
dragPane = null;
|
||||
dragCtrlKey = false;
|
||||
dragInitialSelections.clear();
|
||||
|
||||
// Hide selection rectangle
|
||||
const selectionRect = document.getElementById(pane + 'SelectionRect');
|
||||
selectionRect.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update selection rectangle visual
|
||||
function updateSelectionRectangle(pane) {
|
||||
const selectionRect = document.getElementById(pane + 'SelectionRect');
|
||||
|
||||
const left = Math.min(dragStartX, dragCurrentX);
|
||||
const top = Math.min(dragStartY, dragCurrentY);
|
||||
const width = Math.abs(dragCurrentX - dragStartX);
|
||||
const height = Math.abs(dragCurrentY - dragStartY);
|
||||
|
||||
selectionRect.style.left = left + 'px';
|
||||
selectionRect.style.top = top + 'px';
|
||||
selectionRect.style.width = width + 'px';
|
||||
selectionRect.style.height = height + 'px';
|
||||
selectionRect.style.display = 'block';
|
||||
}
|
||||
|
||||
// Update item selection based on rectangle intersection
|
||||
function updateItemSelection(pane) {
|
||||
const contentArea = document.getElementById(pane + 'ContentArea');
|
||||
const items = contentArea.querySelectorAll('.selectable-item');
|
||||
|
||||
const selectionLeft = Math.min(dragStartX, dragCurrentX);
|
||||
const selectionTop = Math.min(dragStartY, dragCurrentY);
|
||||
const selectionRight = Math.max(dragStartX, dragCurrentX);
|
||||
const selectionBottom = Math.max(dragStartY, dragCurrentY);
|
||||
|
||||
// Track items that should be selected by drag
|
||||
const itemsInSelection = new Set();
|
||||
|
||||
items.forEach(item => {
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
const contentRect = contentArea.getBoundingClientRect();
|
||||
|
||||
const itemLeft = itemRect.left - contentRect.left + contentArea.scrollLeft;
|
||||
const itemTop = itemRect.top - contentRect.top + contentArea.scrollTop;
|
||||
const itemRight = itemLeft + itemRect.width;
|
||||
const itemBottom = itemTop + itemRect.height;
|
||||
|
||||
// Check if item intersects with selection rectangle
|
||||
const intersects = !(itemRight < selectionLeft ||
|
||||
itemLeft > selectionRight ||
|
||||
itemBottom < selectionTop ||
|
||||
itemTop > selectionBottom);
|
||||
|
||||
const itemName = item.dataset.itemName;
|
||||
|
||||
if (intersects) {
|
||||
itemsInSelection.add(itemName);
|
||||
}
|
||||
});
|
||||
|
||||
// Update selection based on whether Ctrl was held at drag start
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
|
||||
if (dragCtrlKey) {
|
||||
// Additive selection - restore initial selections and add drag selections
|
||||
clearAllSelections(pane);
|
||||
|
||||
// Re-add initial selections
|
||||
dragInitialSelections.forEach(itemName => {
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
});
|
||||
|
||||
// Add new drag selections
|
||||
itemsInSelection.forEach(itemName => {
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
});
|
||||
} else {
|
||||
// Replace selection - clear all and select only intersecting items
|
||||
clearAllSelections(pane);
|
||||
itemsInSelection.forEach(itemName => {
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add click handler to clear selection when clicking empty area
|
||||
function handleContentAreaClick(event, pane) {
|
||||
// Don't clear if we just finished a drag selection
|
||||
if (justFinishedDrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only clear selection if clicking on empty area (not on an item)
|
||||
if (!event.target.closest('.selectable-item') && !isDragSelecting) {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
clearAllSelections(pane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Argus Photo Manager - Light Design</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'inter': ['Inter', 'system-ui', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
|
||||
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
.pane-splitter {
|
||||
user-select: none;
|
||||
}
|
||||
.pane-splitter.horizontal {
|
||||
cursor: col-resize;
|
||||
}
|
||||
.pane-splitter.vertical {
|
||||
cursor: row-resize;
|
||||
}
|
||||
.pane-splitter:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
.pane-splitter.dragging {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
.breadcrumb-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.breadcrumb-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.folder-item, .photo-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.folder-item:hover, .photo-item:hover {
|
||||
background-color: #f9fafb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.folder-item:active, .photo-item:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.grid-small {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
}
|
||||
.grid-medium {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
.grid-large {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
.list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.list-item:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
.breadcrumb-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
min-width: 200px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
.breadcrumb-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
.breadcrumb-dropdown-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.breadcrumb-dropdown-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.breadcrumb-separator {
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.breadcrumb-separator:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.path-input-container {
|
||||
position: relative;
|
||||
}
|
||||
.path-input {
|
||||
display: none;
|
||||
min-width: 300px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
.path-input.active {
|
||||
display: block;
|
||||
}
|
||||
.breadcrumb-area {
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.breadcrumb-area.editing {
|
||||
background-color: white;
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
.path-edit-trigger {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.path-edit-trigger:hover {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
.path-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
.path-suggestions.show {
|
||||
display: block;
|
||||
}
|
||||
.path-suggestion-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 13px;
|
||||
}
|
||||
.path-suggestion-item:hover,
|
||||
.path-suggestion-item.selected {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.edit-path-hint {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.item-selected {
|
||||
background-color: #dbeafe !important;
|
||||
border: 2px solid #3b82f6 !important;
|
||||
box-shadow: 0 0 0 1px #3b82f6;
|
||||
}
|
||||
.item-focused {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.selectable-item {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
.selectable-item:focus {
|
||||
outline: none;
|
||||
}
|
||||
.selection-rectangle {
|
||||
position: absolute;
|
||||
border: 2px solid #3b82f6;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.content-area {
|
||||
position: relative;
|
||||
}
|
||||
.pane-container {
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
.pane-container.inactive {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.pane-container.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.pane-container.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, #3b82f6, #60a5fa);
|
||||
z-index: 10;
|
||||
}
|
||||
.pane-inactive-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.015);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
display: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.pane-container.inactive .pane-inactive-overlay {
|
||||
display: block;
|
||||
}
|
||||
/* Active pane indicator for breadcrumb - more subtle */
|
||||
.pane-container.active .breadcrumb-area {
|
||||
background-color: rgba(59, 130, 246, 0.02);
|
||||
}
|
||||
/* Clickable breadcrumb area to activate pane */
|
||||
.breadcrumb-header {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.breadcrumb-header:hover {
|
||||
background-color: rgba(59, 130, 246, 0.03);
|
||||
}
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
padding: 4px;
|
||||
}
|
||||
.context-menu.show {
|
||||
display: block;
|
||||
}
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.context-menu-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.context-menu-item.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.context-menu-item.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
.context-menu-item ion-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.context-menu-separator {
|
||||
height: 1px;
|
||||
background-color: #e5e7eb;
|
||||
margin: 4px 0;
|
||||
}
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
white-space: nowrap;
|
||||
display: none;
|
||||
max-width: 300px;
|
||||
}
|
||||
.tooltip.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 font-inter">
|
||||
<div class="h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="bg-white border-b border-gray-200 px-4 py-3">
|
||||
<!-- File Operations Toolbar -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" title="New Folder">
|
||||
<ion-icon name="add-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm font-medium">New</span>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-300"></div>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Copy">
|
||||
<ion-icon name="copy-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Copy</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Cut">
|
||||
<ion-icon name="cut-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Cut</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Paste" disabled>
|
||||
<ion-icon name="clipboard-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Paste</span>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-300"></div>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Rename">
|
||||
<ion-icon name="create-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Rename</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Delete">
|
||||
<ion-icon name="trash-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Delete</span>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-300"></div>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors" title="Add to Album">
|
||||
<ion-icon name="albums-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Add to Album</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition-colors" title="Edit Tags">
|
||||
<ion-icon name="pricetag-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Tags</span>
|
||||
</button>
|
||||
<button class="flex items-center space-x-2 px-3 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors" title="Batch Edit">
|
||||
<ion-icon name="layers-outline" class="text-sm"></ion-icon>
|
||||
<span class="text-sm">Batch Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- View Controls -->
|
||||
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
|
||||
<button id="gridViewBtn" class="px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700" onclick="setViewMode('grid')">Grid</button>
|
||||
<button id="listViewBtn" class="px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700" onclick="setViewMode('list')">List</button>
|
||||
</div>
|
||||
<!-- Thumbnail Size (only show when Grid is selected) -->
|
||||
<div id="thumbnailSizeControls" class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-600">Size:</span>
|
||||
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<button id="smallBtn" class="p-1 rounded hover:bg-white transition-colors" title="Small" onclick="setThumbnailSize('small')">
|
||||
<ion-icon name="apps-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
<button id="mediumBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Medium" onclick="setThumbnailSize('medium')">
|
||||
<ion-icon name="grid-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
<button id="largeBtn" class="p-1 rounded hover:bg-white transition-colors" title="Large" onclick="setThumbnailSize('large')">
|
||||
<ion-icon name="square-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Layout Toggle -->
|
||||
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<button id="horizontalLayoutBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Horizontal Layout" onclick="setLayoutMode('horizontal')">
|
||||
<ion-icon name="resize-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
<button id="verticalLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Vertical Layout" onclick="setLayoutMode('vertical')">
|
||||
<ion-icon name="swap-vertical-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
<button id="singleLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Single Pane" onclick="setLayoutMode('single')">
|
||||
<ion-icon name="square-outline" class="text-sm"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div id="mainContent" class="flex-1 flex overflow-hidden">
|
||||
<!-- Left Pane -->
|
||||
<div id="leftPane" class="pane-container flex-1 flex flex-col bg-white border-r border-gray-200 active">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="leftBreadcrumb" class="breadcrumb-header px-4 py-2 border-b border-gray-100 bg-gray-50" onclick="activatePaneOnly('left')">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="leftBreadcrumbArea" onclick="event.stopPropagation()">
|
||||
<div id="leftBreadcrumbContent">
|
||||
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-edit-trigger ml-2" onclick="event.stopPropagation(); enterEditMode('left')" title="Edit path">
|
||||
<ion-icon name="create-outline" class="text-gray-500 text-sm"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-input-container">
|
||||
<input type="text" id="leftPathInput" class="path-input px-3 py-2 text-sm border border-gray-300 rounded-lg" placeholder="Enter path..." onblur="exitEditMode('left')" onkeydown="handlePathInputKeydown(event, 'left')" oninput="showPathSuggestions(event, 'left')">
|
||||
<div class="path-suggestions" id="leftPathSuggestions">
|
||||
<!-- Suggestions will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 p-4 overflow-auto content-area" id="leftContentArea" onmousedown="handlePaneMouseDown(event, 'left')" onmouseup="endDragSelection(event, 'left')" onmousemove="updateDragSelection(event, 'left')" onclick="handleContentAreaClick(event, 'left')">
|
||||
<div id="leftContent" class="grid grid-medium gap-4">
|
||||
<!-- Content will be loaded here -->
|
||||
</div>
|
||||
<div id="leftSelectionRect" class="selection-rectangle"></div>
|
||||
</div>
|
||||
<div class="pane-inactive-overlay"></div>
|
||||
</div>
|
||||
|
||||
<!-- Splitter -->
|
||||
<div id="splitter" class="w-1 bg-gray-200 pane-splitter horizontal hidden"></div>
|
||||
|
||||
<!-- Right Pane (hidden by default) -->
|
||||
<div id="rightPane" class="pane-container flex-1 flex-col bg-white hidden inactive">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="rightBreadcrumb" class="breadcrumb-header px-4 py-2 border-b border-gray-100 bg-gray-50" onclick="activatePaneOnly('right')">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="rightBreadcrumbArea" onclick="event.stopPropagation()">
|
||||
<div id="rightBreadcrumbContent">
|
||||
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-edit-trigger ml-2" onclick="event.stopPropagation(); enterEditMode('right')" title="Edit path">
|
||||
<ion-icon name="create-outline" class="text-gray-500 text-sm"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-input-container">
|
||||
<input type="text" id="rightPathInput" class="path-input px-3 py-2 text-sm border border-gray-300 rounded-lg" placeholder="Enter path..." onblur="exitEditMode('right')" onkeydown="handlePathInputKeydown(event, 'right')" oninput="showPathSuggestions(event, 'right')">
|
||||
<div class="path-suggestions" id="rightPathSuggestions">
|
||||
<!-- Suggestions will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 p-4 overflow-auto content-area" id="rightContentArea" onmousedown="handlePaneMouseDown(event, 'right')" onmouseup="endDragSelection(event, 'right')" onmousemove="updateDragSelection(event, 'right')" onclick="handleContentAreaClick(event, 'right')">
|
||||
<div id="rightContent" class="grid grid-medium gap-4">
|
||||
<!-- Content will be loaded here -->
|
||||
</div>
|
||||
<div id="rightSelectionRect" class="selection-rectangle"></div>
|
||||
</div>
|
||||
<div class="pane-inactive-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="contextMenu" class="context-menu">
|
||||
<!-- Menu items will be populated dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div id="tooltip" class="tooltip"></div>
|
||||
|
||||
<script>
|
||||
// Sample data structure
|
||||
const folderStructure = {
|
||||
'Home': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'My Photos': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Vacation 2023': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Beach': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'photo1.jpg': { type: 'photo', size: '2.4 MB', date: '2023-07-15' },
|
||||
'photo2.jpg': { type: 'photo', size: '3.1 MB', date: '2023-07-15' },
|
||||
'photo3.jpg': { type: 'photo', size: '2.8 MB', date: '2023-07-16' },
|
||||
'photo4.jpg': { type: 'photo', size: '4.1 MB', date: '2023-07-16' },
|
||||
'photo5.jpg': { type: 'photo', size: '3.5 MB', date: '2023-07-17' },
|
||||
'beach_sunset.jpg': { type: 'photo', size: '5.2 MB', date: '2023-07-17' },
|
||||
'waves.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-18' },
|
||||
'seashells.jpg': { type: 'photo', size: '2.1 MB', date: '2023-07-18' }
|
||||
}
|
||||
},
|
||||
'Mountains': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'sunrise.jpg': { type: 'photo', size: '4.2 MB', date: '2023-07-20' },
|
||||
'sunset.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-20' },
|
||||
'peak_view.jpg': { type: 'photo', size: '6.1 MB', date: '2023-07-21' },
|
||||
'hiking_trail.jpg': { type: 'photo', size: '4.5 MB', date: '2023-07-21' },
|
||||
'mountain_lake.jpg': { type: 'photo', size: '5.3 MB', date: '2023-07-22' },
|
||||
'forest.jpg': { type: 'photo', size: '4.8 MB', date: '2023-07-22' }
|
||||
}
|
||||
},
|
||||
'City Tour': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'architecture.jpg': { type: 'photo', size: '3.2 MB', date: '2023-07-25' },
|
||||
'street_art.jpg': { type: 'photo', size: '2.7 MB', date: '2023-07-25' },
|
||||
'museum.jpg': { type: 'photo', size: '2.9 MB', date: '2023-07-26' },
|
||||
'cafe.jpg': { type: 'photo', size: '2.3 MB', date: '2023-07-26' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'Family': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Birthday Party': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'cake.jpg': { type: 'photo', size: '2.1 MB', date: '2023-08-10' },
|
||||
'group_photo.jpg': { type: 'photo', size: '3.4 MB', date: '2023-08-10' },
|
||||
'presents.jpg': { type: 'photo', size: '2.8 MB', date: '2023-08-10' }
|
||||
}
|
||||
},
|
||||
'dinner.jpg': { type: 'photo', size: '2.2 MB', date: '2023-08-15' },
|
||||
'family_portrait.jpg': { type: 'photo', size: '4.1 MB', date: '2023-08-20' },
|
||||
'kids_playing.jpg': { type: 'photo', size: '3.3 MB', date: '2023-08-22' },
|
||||
'garden.jpg': { type: 'photo', size: '2.9 MB', date: '2023-08-25' }
|
||||
}
|
||||
},
|
||||
'Events': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'wedding.jpg': { type: 'photo', size: '5.1 MB', date: '2023-09-05' },
|
||||
'graduation.jpg': { type: 'photo', size: '3.7 MB', date: '2023-09-10' },
|
||||
'concert.jpg': { type: 'photo', size: '2.8 MB', date: '2023-09-15' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'Work Projects': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'Screenshots': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'design1.png': { type: 'photo', size: '1.2 MB', date: '2023-09-01' },
|
||||
'design2.png': { type: 'photo', size: '1.5 MB', date: '2023-09-02' },
|
||||
'mockup.png': { type: 'photo', size: '2.1 MB', date: '2023-09-03' },
|
||||
'wireframe.png': { type: 'photo', size: '0.8 MB', date: '2023-09-04' },
|
||||
'prototype.png': { type: 'photo', size: '1.9 MB', date: '2023-09-05' }
|
||||
}
|
||||
},
|
||||
'Presentations': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'slide1.jpg': { type: 'photo', size: '1.3 MB', date: '2023-09-08' },
|
||||
'slide2.jpg': { type: 'photo', size: '1.1 MB', date: '2023-09-08' },
|
||||
'slide3.jpg': { type: 'photo', size: '1.4 MB', date: '2023-09-08' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'Camera Roll': {
|
||||
type: 'folder',
|
||||
children: (() => {
|
||||
const items = {
|
||||
'Recent': {
|
||||
type: 'folder',
|
||||
children: {}
|
||||
},
|
||||
'Favorites': {
|
||||
type: 'folder',
|
||||
children: {}
|
||||
}
|
||||
};
|
||||
// 生成100张照片
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const size = (Math.random() * 3 + 1).toFixed(1);
|
||||
const day = Math.floor(Math.random() * 30) + 1;
|
||||
items[`IMG_${String(i).padStart(4, '0')}.jpg`] = {
|
||||
type: 'photo',
|
||||
size: `${size} MB`,
|
||||
date: `2023-09-${String(day).padStart(2, '0')}`
|
||||
};
|
||||
}
|
||||
// Recent文件夹也添加一些照片
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const size = (Math.random() * 3 + 1).toFixed(1);
|
||||
items.Recent.children[`Recent_${String(i).padStart(3, '0')}.jpg`] = {
|
||||
type: 'photo',
|
||||
size: `${size} MB`,
|
||||
date: `2023-12-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`
|
||||
};
|
||||
}
|
||||
// Favorites文件夹添加照片
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const size = (Math.random() * 4 + 1).toFixed(1);
|
||||
items.Favorites.children[`Favorite_${i}.jpg`] = {
|
||||
type: 'photo',
|
||||
size: `${size} MB`,
|
||||
date: `2023-11-${String(Math.floor(Math.random() * 30) + 1).padStart(2, '0')}`
|
||||
};
|
||||
}
|
||||
return items;
|
||||
})()
|
||||
},
|
||||
'Archives': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'2022': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'old_photo1.jpg': { type: 'photo', size: '2.1 MB', date: '2022-12-25' },
|
||||
'old_photo2.jpg': { type: 'photo', size: '1.8 MB', date: '2022-12-31' }
|
||||
}
|
||||
},
|
||||
'2021': {
|
||||
type: 'folder',
|
||||
children: {
|
||||
'vintage1.jpg': { type: 'photo', size: '1.5 MB', date: '2021-06-15' },
|
||||
'vintage2.jpg': { type: 'photo', size: '1.9 MB', date: '2021-08-20' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Application state
|
||||
let layoutMode = 'single'; // 'single', 'horizontal', 'vertical'
|
||||
let viewMode = 'grid'; // 'grid', 'list'
|
||||
let thumbnailSize = 'medium'; // 'small', 'medium', 'large'
|
||||
let leftPath = ['Home'];
|
||||
let rightPath = ['Home'];
|
||||
let isDragging = false;
|
||||
let activePane = 'left'; // Track which pane is currently active
|
||||
|
||||
// Selection state
|
||||
let leftSelectedItems = new Set();
|
||||
let rightSelectedItems = new Set();
|
||||
let leftFocusedItem = null;
|
||||
let rightFocusedItem = null;
|
||||
let leftAnchorItem = null; // For shift selection
|
||||
let rightAnchorItem = null; // For shift selection
|
||||
|
||||
// Drag selection state
|
||||
let isDragSelecting = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let dragCurrentX = 0;
|
||||
let dragCurrentY = 0;
|
||||
let dragPane = null;
|
||||
let dragInitialSelections = new Set(); // Store selections before drag starts
|
||||
let dragCtrlKey = false; // Store ctrl key state during drag
|
||||
let justFinishedDrag = false; // Prevent click event from clearing selection after drag
|
||||
|
||||
// Context Menu State
|
||||
let contextMenuVisible = false;
|
||||
let contextMenuPane = null;
|
||||
let contextMenuTarget = null;
|
||||
|
||||
// Show context menu
|
||||
function showContextMenu(event, pane, targetItem = null) {
|
||||
event.preventDefault();
|
||||
|
||||
const menu = document.getElementById('contextMenu');
|
||||
contextMenuPane = pane;
|
||||
contextMenuTarget = targetItem;
|
||||
|
||||
// Build menu items based on context
|
||||
let menuHTML = '';
|
||||
|
||||
if (targetItem) {
|
||||
// Right-clicked on an item
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
const isSelected = selectedItems.has(targetItem);
|
||||
const selectionCount = selectedItems.size;
|
||||
|
||||
menuHTML = `
|
||||
<div class="context-menu-item" onclick="openItem('${pane}', '${targetItem}')">
|
||||
<ion-icon name="open-outline"></ion-icon>
|
||||
<span>打开</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" onclick="cutItems('${pane}')">
|
||||
<ion-icon name="cut-outline"></ion-icon>
|
||||
<span>剪切${selectionCount > 1 ? ' (' + selectionCount + ' 项)' : ''}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" onclick="copyItems('${pane}')">
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
<span>复制${selectionCount > 1 ? ' (' + selectionCount + ' 项)' : ''}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" onclick="deleteItems('${pane}')">
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
<span>删除${selectionCount > 1 ? ' (' + selectionCount + ' 项)' : ''}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" onclick="renameItem('${pane}', '${targetItem}')">
|
||||
<ion-icon name="create-outline"></ion-icon>
|
||||
<span>重命名</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" onclick="showProperties('${pane}', '${targetItem}')">
|
||||
<ion-icon name="information-circle-outline"></ion-icon>
|
||||
<span>属性</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Right-clicked on empty area
|
||||
menuHTML = `
|
||||
<div class="context-menu-item" onclick="pasteItems('${pane}')">
|
||||
<ion-icon name="clipboard-outline"></ion-icon>
|
||||
<span>粘贴</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" onclick="createNewFolder('${pane}')">
|
||||
<ion-icon name="folder-outline"></ion-icon>
|
||||
<span>新建文件夹</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" onclick="selectAllItems('${pane}')">
|
||||
<ion-icon name="checkbox-outline"></ion-icon>
|
||||
<span>全选</span>
|
||||
</div>
|
||||
<div class="context-menu-item" onclick="refreshPane('${pane}')">
|
||||
<ion-icon name="refresh-outline"></ion-icon>
|
||||
<span>刷新</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
menu.innerHTML = menuHTML;
|
||||
|
||||
// Position the menu
|
||||
const menuWidth = 200;
|
||||
const menuHeight = menu.offsetHeight || 300;
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
|
||||
// Adjust if menu would go off screen
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
x = window.innerWidth - menuWidth - 10;
|
||||
}
|
||||
if (y + menuHeight > window.innerHeight) {
|
||||
y = window.innerHeight - menuHeight - 10;
|
||||
}
|
||||
|
||||
menu.style.left = x + 'px';
|
||||
menu.style.top = y + 'px';
|
||||
menu.classList.add('show');
|
||||
contextMenuVisible = true;
|
||||
|
||||
// Hide menu on any click
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', hideContextMenu, { once: true });
|
||||
document.addEventListener('contextmenu', hideContextMenu, { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Hide context menu
|
||||
function hideContextMenu() {
|
||||
const menu = document.getElementById('contextMenu');
|
||||
menu.classList.remove('show');
|
||||
contextMenuVisible = false;
|
||||
contextMenuPane = null;
|
||||
contextMenuTarget = null;
|
||||
}
|
||||
|
||||
// Context menu actions (placeholder implementations)
|
||||
function openItem(pane, itemName) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const currentFolder = getCurrentFolder(path);
|
||||
const item = currentFolder[itemName];
|
||||
|
||||
if (item && item.type === 'folder') {
|
||||
navigateToFolder(pane, itemName);
|
||||
}
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function cutItems(pane) {
|
||||
console.log('Cut items from', pane);
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function copyItems(pane) {
|
||||
console.log('Copy items from', pane);
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function pasteItems(pane) {
|
||||
console.log('Paste items to', pane);
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function deleteItems(pane) {
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
console.log('Delete items:', Array.from(selectedItems));
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function renameItem(pane, itemName) {
|
||||
console.log('Rename item:', itemName);
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function createNewFolder(pane) {
|
||||
console.log('Create new folder in', pane);
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function selectAllItems(pane) {
|
||||
const items = getVisibleItems(pane);
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
|
||||
clearAllSelections(pane);
|
||||
items.forEach(item => {
|
||||
selectedItems.add(item.name);
|
||||
addItemSelection(pane, item.name);
|
||||
});
|
||||
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function refreshPane(pane) {
|
||||
loadContent(pane);
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function showProperties(pane, itemName) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const currentFolder = getCurrentFolder(path);
|
||||
const item = currentFolder[itemName];
|
||||
|
||||
if (item) {
|
||||
alert(`名称: ${itemName}\n类型: ${item.type === 'folder' ? '文件夹' : '文件'}\n大小: ${item.size || 'N/A'}\n日期: ${item.date || 'N/A'}`);
|
||||
}
|
||||
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
// Tooltip functions
|
||||
let tooltipTimeout = null;
|
||||
|
||||
function showTooltip(event, text) {
|
||||
clearTimeout(tooltipTimeout);
|
||||
|
||||
tooltipTimeout = setTimeout(() => {
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
tooltip.textContent = text;
|
||||
tooltip.classList.add('show');
|
||||
|
||||
// Position tooltip
|
||||
const x = event.clientX + 10;
|
||||
const y = event.clientY + 10;
|
||||
|
||||
tooltip.style.left = x + 'px';
|
||||
tooltip.style.top = y + 'px';
|
||||
}, 500); // Show after 500ms hover
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
clearTimeout(tooltipTimeout);
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
tooltip.classList.remove('show');
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
function init() {
|
||||
loadContent('left');
|
||||
updateBreadcrumb('left');
|
||||
setupSplitter();
|
||||
setupKeyboardNavigation();
|
||||
setupContextMenu();
|
||||
}
|
||||
|
||||
// Setup context menu event listeners
|
||||
function setupContextMenu() {
|
||||
// Prevent default context menu on content areas
|
||||
document.getElementById('leftContentArea').addEventListener('contextmenu', (e) => {
|
||||
const item = e.target.closest('.selectable-item');
|
||||
if (item) {
|
||||
const itemName = item.dataset.itemName;
|
||||
showContextMenu(e, 'left', itemName);
|
||||
} else {
|
||||
showContextMenu(e, 'left', null);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('rightContentArea').addEventListener('contextmenu', (e) => {
|
||||
const item = e.target.closest('.selectable-item');
|
||||
if (item) {
|
||||
const itemName = item.dataset.itemName;
|
||||
showContextMenu(e, 'right', itemName);
|
||||
} else {
|
||||
showContextMenu(e, 'right', null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get current folder from path
|
||||
function getCurrentFolder(path) {
|
||||
let current = folderStructure;
|
||||
for (let segment of path) {
|
||||
if (current[segment] && current[segment].children) {
|
||||
current = current[segment].children;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
// Load content for a pane
|
||||
function loadContent(pane) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const contentElement = document.getElementById(pane + 'Content');
|
||||
const currentFolder = getCurrentFolder(path);
|
||||
|
||||
// Clear selection when navigating
|
||||
if (pane === 'left') {
|
||||
leftSelectedItems.clear();
|
||||
leftFocusedItem = null;
|
||||
leftAnchorItem = null;
|
||||
} else {
|
||||
rightSelectedItems.clear();
|
||||
rightFocusedItem = null;
|
||||
rightAnchorItem = null;
|
||||
}
|
||||
|
||||
if (!currentFolder) {
|
||||
contentElement.innerHTML = '<p class="text-gray-500">Folder not found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort items: folders first, then photos
|
||||
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (viewMode === 'list') {
|
||||
contentElement.className = 'list-view';
|
||||
let html = '';
|
||||
|
||||
items.forEach(([name, item], index) => {
|
||||
const itemId = `${pane}-item-${index}`;
|
||||
if (item.type === 'folder') {
|
||||
html += `
|
||||
<div class="list-item folder-item selectable-item" data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
|
||||
onclick="handleItemClick(event, '${pane}', '${name}', ${index})"
|
||||
ondblclick="navigateToFolder('${pane}', '${name}')">
|
||||
<ion-icon name="folder-outline" class="text-blue-500 text-lg mr-3"></ion-icon>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-700">${name}</p>
|
||||
<p class="text-xs text-gray-500">Folder</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (item.type === 'photo') {
|
||||
html += `
|
||||
<div class="list-item photo-item selectable-item" data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
|
||||
onclick="handleItemClick(event, '${pane}', '${name}', ${index})">
|
||||
<ion-icon name="image-outline" class="text-green-500 text-lg mr-3"></ion-icon>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-700">${name}</p>
|
||||
<p class="text-xs text-gray-500">${item.size} • ${item.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
contentElement.innerHTML = html;
|
||||
} else {
|
||||
// Grid view
|
||||
contentElement.className = `grid grid-${thumbnailSize} gap-4`;
|
||||
let html = '';
|
||||
|
||||
items.forEach(([name, item], index) => {
|
||||
const itemId = `${pane}-item-${index}`;
|
||||
if (item.type === 'folder') {
|
||||
html += `
|
||||
<div class="folder-item selectable-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all"
|
||||
data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
|
||||
onclick="handleItemClick(event, '${pane}', '${name}', ${index})"
|
||||
ondblclick="navigateToFolder('${pane}', '${name}')">
|
||||
<ion-icon name="folder-outline" class="text-blue-500 text-3xl mb-2"></ion-icon>
|
||||
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
|
||||
</div>
|
||||
`;
|
||||
} else if (item.type === 'photo') {
|
||||
html += `
|
||||
<div class="photo-item selectable-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all"
|
||||
data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
|
||||
onclick="handleItemClick(event, '${pane}', '${name}', ${index})">
|
||||
<ion-icon name="image-outline" class="text-green-500 text-3xl mb-2"></ion-icon>
|
||||
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
|
||||
<p class="text-xs text-gray-500">${item.size}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
contentElement.innerHTML = html;
|
||||
}
|
||||
|
||||
// Set default focus to first item (but don't select it)
|
||||
if (items.length > 0) {
|
||||
if (pane === 'left') {
|
||||
leftFocusedItem = { name: items[0][0], index: 0 };
|
||||
leftAnchorItem = { name: items[0][0], index: 0 };
|
||||
} else {
|
||||
rightFocusedItem = { name: items[0][0], index: 0 };
|
||||
rightAnchorItem = { name: items[0][0], index: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to folder
|
||||
function navigateToFolder(pane, folderName) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
path.push(folderName);
|
||||
|
||||
loadContent(pane);
|
||||
updateBreadcrumb(pane);
|
||||
}
|
||||
|
||||
// Update breadcrumb navigation
|
||||
function updateBreadcrumb(pane) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const breadcrumbContentElement = document.getElementById(pane + 'BreadcrumbContent');
|
||||
|
||||
let html = '<div class="flex items-center space-x-1">';
|
||||
|
||||
path.forEach((segment, index) => {
|
||||
if (index > 0) {
|
||||
html += `
|
||||
<div class="breadcrumb-separator" onclick="showBreadcrumbDropdown('${pane}', ${index - 1})">
|
||||
<ion-icon name="chevron-forward-outline" class="text-gray-400 text-xs"></ion-icon>
|
||||
<div class="breadcrumb-dropdown" id="${pane}Dropdown${index - 1}">
|
||||
<!-- Dropdown content will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (index === path.length - 1) {
|
||||
html += `<span class="px-2 py-1 rounded bg-blue-100 text-blue-700 font-medium">${segment}</span>`;
|
||||
} else {
|
||||
html += `<span class="breadcrumb-item px-2 py-1 rounded text-gray-600 hover:bg-gray-100" onclick="navigateToBreadcrumb('${pane}', ${index})">${segment}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
breadcrumbContentElement.innerHTML = html;
|
||||
}
|
||||
|
||||
// Navigate via breadcrumb
|
||||
function navigateToBreadcrumb(pane, index) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
|
||||
if (pane === 'left') {
|
||||
leftPath = leftPath.slice(0, index + 1);
|
||||
} else {
|
||||
rightPath = rightPath.slice(0, index + 1);
|
||||
}
|
||||
|
||||
loadContent(pane);
|
||||
updateBreadcrumb(pane);
|
||||
}
|
||||
|
||||
// Set view mode (grid/list)
|
||||
function setViewMode(mode) {
|
||||
viewMode = mode;
|
||||
|
||||
// Update button states
|
||||
document.getElementById('gridViewBtn').className = mode === 'grid'
|
||||
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
|
||||
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
|
||||
|
||||
document.getElementById('listViewBtn').className = mode === 'list'
|
||||
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
|
||||
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
|
||||
|
||||
// Show/hide thumbnail size controls based on view mode
|
||||
const thumbnailControls = document.getElementById('thumbnailSizeControls');
|
||||
if (mode === 'grid') {
|
||||
thumbnailControls.style.display = 'flex';
|
||||
} else {
|
||||
thumbnailControls.style.display = 'none';
|
||||
}
|
||||
|
||||
// Reload content
|
||||
loadContent('left');
|
||||
if (layoutMode !== 'single') {
|
||||
loadContent('right');
|
||||
}
|
||||
}
|
||||
|
||||
// Set thumbnail size
|
||||
function setThumbnailSize(size) {
|
||||
thumbnailSize = size;
|
||||
|
||||
// Update button states
|
||||
['small', 'medium', 'large'].forEach(s => {
|
||||
document.getElementById(s + 'Btn').className = s === size
|
||||
? 'p-1 rounded bg-white shadow-sm transition-colors'
|
||||
: 'p-1 rounded hover:bg-white transition-colors';
|
||||
});
|
||||
|
||||
// Reload content if in grid mode
|
||||
if (viewMode === 'grid') {
|
||||
loadContent('left');
|
||||
if (layoutMode !== 'single') {
|
||||
loadContent('right');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set layout mode
|
||||
function setLayoutMode(mode) {
|
||||
layoutMode = mode;
|
||||
|
||||
const rightPane = document.getElementById('rightPane');
|
||||
const splitter = document.getElementById('splitter');
|
||||
const mainContent = document.getElementById('mainContent');
|
||||
|
||||
// Update button states
|
||||
['horizontal', 'vertical', 'single'].forEach(m => {
|
||||
const btn = document.getElementById(m + 'LayoutBtn');
|
||||
btn.className = m === mode
|
||||
? 'p-1 rounded bg-white shadow-sm transition-colors'
|
||||
: 'p-1 rounded hover:bg-white transition-colors';
|
||||
});
|
||||
|
||||
if (mode === 'single') {
|
||||
rightPane.classList.add('hidden');
|
||||
rightPane.classList.remove('flex');
|
||||
splitter.classList.add('hidden');
|
||||
mainContent.style.flexDirection = '';
|
||||
mainContent.style.gridTemplateColumns = '';
|
||||
mainContent.style.gridTemplateRows = '';
|
||||
} else if (mode === 'horizontal') {
|
||||
rightPane.classList.remove('hidden');
|
||||
rightPane.classList.add('flex');
|
||||
splitter.classList.remove('hidden');
|
||||
splitter.className = 'w-1 bg-gray-200 pane-splitter horizontal';
|
||||
mainContent.style.flexDirection = 'row';
|
||||
mainContent.style.gridTemplateColumns = '1fr 4px 1fr';
|
||||
mainContent.style.gridTemplateRows = '';
|
||||
mainContent.style.display = 'grid';
|
||||
loadContent('right');
|
||||
updateBreadcrumb('right');
|
||||
} else if (mode === 'vertical') {
|
||||
rightPane.classList.remove('hidden');
|
||||
rightPane.classList.add('flex');
|
||||
splitter.classList.remove('hidden');
|
||||
splitter.className = 'h-1 bg-gray-200 pane-splitter vertical';
|
||||
mainContent.style.flexDirection = 'column';
|
||||
mainContent.style.gridTemplateRows = '1fr 4px 1fr';
|
||||
mainContent.style.gridTemplateColumns = '';
|
||||
mainContent.style.display = 'grid';
|
||||
loadContent('right');
|
||||
updateBreadcrumb('right');
|
||||
}
|
||||
}
|
||||
|
||||
// Setup splitter functionality
|
||||
function setupSplitter() {
|
||||
const splitter = document.getElementById('splitter');
|
||||
const mainContent = document.getElementById('mainContent');
|
||||
|
||||
splitter.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
splitter.classList.add('dragging');
|
||||
|
||||
if (layoutMode === 'horizontal') {
|
||||
document.body.style.cursor = 'col-resize';
|
||||
} else if (layoutMode === 'vertical') {
|
||||
document.body.style.cursor = 'row-resize';
|
||||
}
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const rect = mainContent.getBoundingClientRect();
|
||||
|
||||
if (layoutMode === 'horizontal') {
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = (x / rect.width) * 100;
|
||||
|
||||
if (percentage > 20 && percentage < 80) {
|
||||
mainContent.style.gridTemplateColumns = `${percentage}% 4px 1fr`;
|
||||
}
|
||||
} else if (layoutMode === 'vertical') {
|
||||
const y = e.clientY - rect.top;
|
||||
const percentage = (y / rect.height) * 100;
|
||||
|
||||
if (percentage > 20 && percentage < 80) {
|
||||
mainContent.style.gridTemplateRows = `${percentage}% 4px 1fr`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
splitter.classList.remove('dragging');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show breadcrumb dropdown
|
||||
function showBreadcrumbDropdown(pane, index) {
|
||||
// Hide all other dropdowns
|
||||
document.querySelectorAll('.breadcrumb-dropdown').forEach(dropdown => {
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const dropdownId = `${pane}Dropdown${index}`;
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
|
||||
if (!dropdown) return;
|
||||
|
||||
// Get folders at the specified path
|
||||
const currentPath = path.slice(0, index + 1);
|
||||
const currentFolder = getCurrentFolder(currentPath);
|
||||
|
||||
if (!currentFolder) return;
|
||||
|
||||
// Build dropdown content
|
||||
let html = '';
|
||||
Object.entries(currentFolder).forEach(([name, item]) => {
|
||||
if (item.type === 'folder') {
|
||||
html += `
|
||||
<div class="breadcrumb-dropdown-item" onclick="navigateFromBreadcrumbDropdown('${pane}', ${index}, '${name}')">
|
||||
<ion-icon name="folder-outline" class="text-blue-500 text-sm mr-2"></ion-icon>
|
||||
<span class="text-sm">${name}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
if (html === '') {
|
||||
html = '<div class="breadcrumb-dropdown-item"><span class="text-sm text-gray-500">No folders</span></div>';
|
||||
}
|
||||
|
||||
dropdown.innerHTML = html;
|
||||
dropdown.classList.add('show');
|
||||
|
||||
// Hide dropdown when clicking outside
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', function hideDropdown(e) {
|
||||
if (!dropdown.contains(e.target) && !e.target.closest('.breadcrumb-separator')) {
|
||||
dropdown.classList.remove('show');
|
||||
document.removeEventListener('click', hideDropdown);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Navigate from breadcrumb dropdown
|
||||
function navigateFromBreadcrumbDropdown(pane, index, folderName) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const newPath = path.slice(0, index + 1);
|
||||
newPath.push(folderName);
|
||||
|
||||
if (pane === 'left') {
|
||||
leftPath = newPath;
|
||||
} else {
|
||||
rightPath = newPath;
|
||||
}
|
||||
|
||||
loadContent(pane);
|
||||
updateBreadcrumb(pane);
|
||||
|
||||
// Hide dropdown immediately after selection
|
||||
const dropdown = document.getElementById(`${pane}Dropdown${index}`);
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
// Enter edit mode (address bar style)
|
||||
function enterEditMode(pane) {
|
||||
const breadcrumbArea = document.getElementById(pane + 'BreadcrumbArea');
|
||||
const editTrigger = breadcrumbArea.parentElement.querySelector('.path-edit-trigger');
|
||||
const inputElement = document.getElementById(pane + 'PathInput');
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
|
||||
breadcrumbArea.style.display = 'none';
|
||||
editTrigger.style.display = 'none';
|
||||
inputElement.classList.add('active');
|
||||
inputElement.value = path.join('/');
|
||||
inputElement.focus();
|
||||
inputElement.select();
|
||||
}
|
||||
|
||||
// Exit edit mode
|
||||
function exitEditMode(pane) {
|
||||
const breadcrumbArea = document.getElementById(pane + 'BreadcrumbArea');
|
||||
const editTrigger = breadcrumbArea.parentElement.querySelector('.path-edit-trigger');
|
||||
const inputElement = document.getElementById(pane + 'PathInput');
|
||||
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
|
||||
|
||||
breadcrumbArea.style.display = 'flex';
|
||||
editTrigger.style.display = 'block';
|
||||
inputElement.classList.remove('active');
|
||||
suggestionsElement.classList.remove('show');
|
||||
}
|
||||
|
||||
// Handle path input keydown
|
||||
function handlePathInputKeydown(event, pane) {
|
||||
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
|
||||
const suggestions = suggestionsElement.querySelectorAll('.path-suggestion-item');
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
const inputElement = document.getElementById(pane + 'PathInput');
|
||||
const pathString = inputElement.value.trim();
|
||||
|
||||
if (pathString) {
|
||||
navigateToPath(pane, pathString);
|
||||
}
|
||||
|
||||
exitEditMode(pane);
|
||||
} else if (event.key === 'Escape') {
|
||||
exitEditMode(pane);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const selected = suggestionsElement.querySelector('.selected');
|
||||
if (selected && selected.nextElementSibling) {
|
||||
selected.classList.remove('selected');
|
||||
selected.nextElementSibling.classList.add('selected');
|
||||
} else if (suggestions.length > 0) {
|
||||
suggestions.forEach(s => s.classList.remove('selected'));
|
||||
suggestions[0].classList.add('selected');
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const selected = suggestionsElement.querySelector('.selected');
|
||||
if (selected && selected.previousElementSibling) {
|
||||
selected.classList.remove('selected');
|
||||
selected.previousElementSibling.classList.add('selected');
|
||||
} else if (suggestions.length > 0) {
|
||||
suggestions.forEach(s => s.classList.remove('selected'));
|
||||
suggestions[suggestions.length - 1].classList.add('selected');
|
||||
}
|
||||
} else if (event.key === 'Tab' && suggestions.length > 0) {
|
||||
event.preventDefault();
|
||||
const selected = suggestionsElement.querySelector('.selected') || suggestions[0];
|
||||
if (selected) {
|
||||
document.getElementById(pane + 'PathInput').value = selected.dataset.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show path suggestions
|
||||
function showPathSuggestions(event, pane) {
|
||||
const inputValue = event.target.value.trim();
|
||||
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
|
||||
|
||||
if (!inputValue) {
|
||||
suggestionsElement.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all possible paths
|
||||
const allPaths = getAllPossiblePaths();
|
||||
const filteredPaths = allPaths.filter(path =>
|
||||
path.toLowerCase().includes(inputValue.toLowerCase()) && path !== inputValue
|
||||
);
|
||||
|
||||
if (filteredPaths.length === 0) {
|
||||
suggestionsElement.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
filteredPaths.slice(0, 8).forEach((path, index) => {
|
||||
html += `
|
||||
<div class="path-suggestion-item ${index === 0 ? 'selected' : ''}"
|
||||
data-path="${path}"
|
||||
onclick="selectPathSuggestion('${pane}', '${path}')">
|
||||
<ion-icon name="folder-outline" class="text-blue-500 text-sm mr-2"></ion-icon>
|
||||
<span>${path}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
suggestionsElement.innerHTML = html;
|
||||
suggestionsElement.classList.add('show');
|
||||
}
|
||||
|
||||
// Get all possible paths
|
||||
function getAllPossiblePaths() {
|
||||
const paths = [];
|
||||
|
||||
function traverseFolder(currentPath, folder) {
|
||||
Object.entries(folder).forEach(([name, item]) => {
|
||||
if (item.type === 'folder') {
|
||||
const newPath = currentPath ? `${currentPath}/${name}` : name;
|
||||
paths.push(newPath);
|
||||
if (item.children) {
|
||||
traverseFolder(newPath, item.children);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
traverseFolder('', folderStructure);
|
||||
return paths.sort();
|
||||
}
|
||||
|
||||
// Select path suggestion
|
||||
function selectPathSuggestion(pane, path) {
|
||||
document.getElementById(pane + 'PathInput').value = path;
|
||||
navigateToPath(pane, path);
|
||||
exitEditMode(pane);
|
||||
}
|
||||
|
||||
// Navigate to path
|
||||
function navigateToPath(pane, pathString) {
|
||||
const pathSegments = pathString.split('/').filter(segment => segment.trim() !== '');
|
||||
|
||||
// Always start from Home
|
||||
if (pathSegments.length === 0 || pathSegments[0] !== 'Home') {
|
||||
pathSegments.unshift('Home');
|
||||
}
|
||||
|
||||
// Validate path exists
|
||||
let current = folderStructure;
|
||||
for (let i = 0; i < pathSegments.length; i++) {
|
||||
const segment = pathSegments[i];
|
||||
if (current[segment] && current[segment].children) {
|
||||
current = current[segment].children;
|
||||
} else if (i === pathSegments.length - 1 && current[segment]) {
|
||||
// Last segment can be a file or folder
|
||||
break;
|
||||
} else {
|
||||
// Invalid path
|
||||
alert('Path not found: ' + pathSegments.slice(0, i + 1).join('/'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set new path
|
||||
if (pane === 'left') {
|
||||
leftPath = pathSegments;
|
||||
} else {
|
||||
rightPath = pathSegments;
|
||||
}
|
||||
|
||||
loadContent(pane);
|
||||
updateBreadcrumb(pane);
|
||||
}
|
||||
|
||||
// Handle item click for selection
|
||||
function handleItemClick(event, pane, itemName, index) {
|
||||
event.stopPropagation();
|
||||
|
||||
// Activate the pane when clicking an item
|
||||
setActivePane(pane);
|
||||
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
const items = getVisibleItems(pane);
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Range selection with Shift
|
||||
const anchorItem = pane === 'left' ? leftAnchorItem : rightAnchorItem;
|
||||
|
||||
if (anchorItem !== null) {
|
||||
// Clear previous selections if not holding Ctrl
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
clearAllSelections(pane);
|
||||
}
|
||||
|
||||
// Select range from anchor to current
|
||||
const startIndex = Math.min(anchorItem.index, index);
|
||||
const endIndex = Math.max(anchorItem.index, index);
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (items[i]) {
|
||||
selectedItems.add(items[i].name);
|
||||
addItemSelection(pane, items[i].name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No anchor, treat as normal click
|
||||
clearAllSelections(pane);
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
|
||||
// Set anchor
|
||||
if (pane === 'left') {
|
||||
leftAnchorItem = { name: itemName, index: index };
|
||||
} else {
|
||||
rightAnchorItem = { name: itemName, index: index };
|
||||
}
|
||||
}
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Toggle selection with Ctrl/Cmd
|
||||
if (selectedItems.has(itemName)) {
|
||||
selectedItems.delete(itemName);
|
||||
removeItemSelection(pane, itemName);
|
||||
} else {
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
}
|
||||
|
||||
// Update anchor
|
||||
if (pane === 'left') {
|
||||
leftAnchorItem = { name: itemName, index: index };
|
||||
} else {
|
||||
rightAnchorItem = { name: itemName, index: index };
|
||||
}
|
||||
} else {
|
||||
// Single select - clear all previous selections
|
||||
clearAllSelections(pane);
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
|
||||
// Set anchor
|
||||
if (pane === 'left') {
|
||||
leftAnchorItem = { name: itemName, index: index };
|
||||
} else {
|
||||
rightAnchorItem = { name: itemName, index: index };
|
||||
}
|
||||
}
|
||||
|
||||
setFocusedItem(pane, itemName, index);
|
||||
}
|
||||
|
||||
// Set focused item
|
||||
function setFocusedItem(pane, itemName, index) {
|
||||
// Clear previous focus in the specific pane
|
||||
const contentArea = document.getElementById(pane + 'ContentArea');
|
||||
if (contentArea) {
|
||||
contentArea.querySelectorAll('.item-focused').forEach(el => {
|
||||
el.classList.remove('item-focused');
|
||||
});
|
||||
}
|
||||
|
||||
if (pane === 'left') {
|
||||
leftFocusedItem = { name: itemName, index: index };
|
||||
} else {
|
||||
rightFocusedItem = { name: itemName, index: index };
|
||||
}
|
||||
|
||||
// Add focus to new item
|
||||
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
|
||||
if (itemElement) {
|
||||
itemElement.classList.add('item-focused');
|
||||
itemElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Add visual selection to item
|
||||
function addItemSelection(pane, itemName) {
|
||||
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
|
||||
if (itemElement) {
|
||||
itemElement.classList.add('item-selected');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove visual selection from item
|
||||
function removeItemSelection(pane, itemName) {
|
||||
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
|
||||
if (itemElement) {
|
||||
itemElement.classList.remove('item-selected');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all selections in a pane
|
||||
function clearAllSelections(pane) {
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
selectedItems.forEach(itemName => {
|
||||
removeItemSelection(pane, itemName);
|
||||
});
|
||||
selectedItems.clear();
|
||||
}
|
||||
|
||||
// Setup keyboard navigation
|
||||
function setupKeyboardNavigation() {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const activePane = getActivePane();
|
||||
if (!activePane) return;
|
||||
|
||||
// Don't interfere with path input
|
||||
if (event.target.tagName === 'INPUT') return;
|
||||
|
||||
const items = getVisibleItems(activePane);
|
||||
if (items.length === 0) return;
|
||||
|
||||
const focusedItem = activePane === 'left' ? leftFocusedItem : rightFocusedItem;
|
||||
const selectedItems = activePane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
|
||||
// If no focused item, focus first item
|
||||
if (!focusedItem && items.length > 0) {
|
||||
setFocusedItem(activePane, items[0].name, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!focusedItem) return;
|
||||
|
||||
let newIndex = focusedItem.index;
|
||||
let shouldUpdateFocus = false;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (viewMode === 'list') {
|
||||
newIndex = Math.max(0, focusedItem.index - 1);
|
||||
} else {
|
||||
// Grid view - move up by number of columns
|
||||
const cols = getGridColumns(activePane);
|
||||
newIndex = Math.max(0, focusedItem.index - cols);
|
||||
}
|
||||
shouldUpdateFocus = true;
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (viewMode === 'list') {
|
||||
newIndex = Math.min(items.length - 1, focusedItem.index + 1);
|
||||
} else {
|
||||
// Grid view - move down by number of columns
|
||||
const cols = getGridColumns(activePane);
|
||||
newIndex = Math.min(items.length - 1, focusedItem.index + cols);
|
||||
}
|
||||
shouldUpdateFocus = true;
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
if (viewMode === 'grid') {
|
||||
event.preventDefault();
|
||||
newIndex = Math.max(0, focusedItem.index - 1);
|
||||
shouldUpdateFocus = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
if (viewMode === 'grid') {
|
||||
event.preventDefault();
|
||||
newIndex = Math.min(items.length - 1, focusedItem.index + 1);
|
||||
shouldUpdateFocus = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
const currentItem = items[focusedItem.index];
|
||||
if (currentItem && currentItem.type === 'folder') {
|
||||
navigateToFolder(activePane, currentItem.name);
|
||||
}
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
// Space toggles selection of focused item
|
||||
const currentItemName = items[focusedItem.index].name;
|
||||
if (selectedItems.has(currentItemName)) {
|
||||
selectedItems.delete(currentItemName);
|
||||
removeItemSelection(activePane, currentItemName);
|
||||
} else {
|
||||
selectedItems.add(currentItemName);
|
||||
addItemSelection(activePane, currentItemName);
|
||||
}
|
||||
|
||||
// Update anchor
|
||||
if (activePane === 'left') {
|
||||
leftAnchorItem = { name: currentItemName, index: focusedItem.index };
|
||||
} else {
|
||||
rightAnchorItem = { name: currentItemName, index: focusedItem.index };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'a':
|
||||
// Ctrl+A to select all
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
clearAllSelections(activePane);
|
||||
items.forEach(item => {
|
||||
selectedItems.add(item.name);
|
||||
addItemSelection(activePane, item.name);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle focus and selection changes for arrow keys
|
||||
if (shouldUpdateFocus && newIndex !== focusedItem.index) {
|
||||
const newItem = items[newIndex];
|
||||
if (!newItem) return;
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift+Arrow: extend selection from anchor to new position
|
||||
const anchorItem = activePane === 'left' ? leftAnchorItem : rightAnchorItem;
|
||||
|
||||
if (anchorItem !== null) {
|
||||
// Clear and reselect range
|
||||
clearAllSelections(activePane);
|
||||
|
||||
const startIndex = Math.min(anchorItem.index, newIndex);
|
||||
const endIndex = Math.max(anchorItem.index, newIndex);
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (items[i]) {
|
||||
selectedItems.add(items[i].name);
|
||||
addItemSelection(activePane, items[i].name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No anchor, select from current to new
|
||||
const startIndex = Math.min(focusedItem.index, newIndex);
|
||||
const endIndex = Math.max(focusedItem.index, newIndex);
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (items[i]) {
|
||||
selectedItems.add(items[i].name);
|
||||
addItemSelection(activePane, items[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
// Set anchor to starting position
|
||||
if (activePane === 'left') {
|
||||
leftAnchorItem = { name: focusedItem.name, index: focusedItem.index };
|
||||
} else {
|
||||
rightAnchorItem = { name: focusedItem.name, index: focusedItem.index };
|
||||
}
|
||||
}
|
||||
|
||||
setFocusedItem(activePane, newItem.name, newIndex);
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl+Arrow: move focus only, don't change selection
|
||||
setFocusedItem(activePane, newItem.name, newIndex);
|
||||
} else {
|
||||
// Plain arrow: move focus and select only the new item
|
||||
clearAllSelections(activePane);
|
||||
selectedItems.add(newItem.name);
|
||||
addItemSelection(activePane, newItem.name);
|
||||
setFocusedItem(activePane, newItem.name, newIndex);
|
||||
|
||||
// Update anchor
|
||||
if (activePane === 'left') {
|
||||
leftAnchorItem = { name: newItem.name, index: newIndex };
|
||||
} else {
|
||||
rightAnchorItem = { name: newItem.name, index: newIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get currently active pane
|
||||
function getActivePane() {
|
||||
return activePane;
|
||||
}
|
||||
|
||||
// Get visible items in current folder
|
||||
function getVisibleItems(pane) {
|
||||
const path = pane === 'left' ? leftPath : rightPath;
|
||||
const currentFolder = getCurrentFolder(path);
|
||||
|
||||
if (!currentFolder) return [];
|
||||
|
||||
// Sort items: folders first, then photos
|
||||
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return items.map(([name, item], index) => ({ name, type: item.type, index }));
|
||||
}
|
||||
|
||||
// Get number of columns in grid view
|
||||
function getGridColumns(pane) {
|
||||
const contentElement = document.getElementById((pane || activePane) + 'Content');
|
||||
if (!contentElement) return 4;
|
||||
|
||||
const items = contentElement.querySelectorAll('.selectable-item');
|
||||
if (items.length < 2) return 1;
|
||||
|
||||
// 计算实际列数:检查前两个元素的位置
|
||||
const firstRect = items[0].getBoundingClientRect();
|
||||
const secondRect = items[1].getBoundingClientRect();
|
||||
|
||||
// 如果第二个元素在第一个右边(同一行),继续计算列数
|
||||
if (Math.abs(secondRect.top - firstRect.top) < 10) {
|
||||
// 它们在同一行,计算一行有多少个
|
||||
let cols = 1;
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const rect = items[i].getBoundingClientRect();
|
||||
if (Math.abs(rect.top - firstRect.top) < 10) {
|
||||
cols++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
// 第二个元素在第一个下面,说明只有一列
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Set active pane
|
||||
function setActivePane(pane) {
|
||||
activePane = pane;
|
||||
|
||||
const leftPane = document.getElementById('leftPane');
|
||||
const rightPane = document.getElementById('rightPane');
|
||||
|
||||
if (pane === 'left') {
|
||||
leftPane.classList.add('active');
|
||||
leftPane.classList.remove('inactive');
|
||||
if (!rightPane.classList.contains('hidden')) {
|
||||
rightPane.classList.add('inactive');
|
||||
rightPane.classList.remove('active');
|
||||
}
|
||||
} else {
|
||||
rightPane.classList.add('active');
|
||||
rightPane.classList.remove('inactive');
|
||||
leftPane.classList.add('inactive');
|
||||
leftPane.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Activate pane without clearing selections (for breadcrumb clicks)
|
||||
function activatePaneOnly(pane) {
|
||||
setActivePane(pane);
|
||||
|
||||
// Set focus to the first selected item or focused item if any
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
const focusedItem = pane === 'left' ? leftFocusedItem : rightFocusedItem;
|
||||
|
||||
if (selectedItems.size > 0 && focusedItem) {
|
||||
// Re-focus the existing focused item to ensure keyboard navigation works
|
||||
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${focusedItem.name}"]`);
|
||||
if (itemElement) {
|
||||
itemElement.focus();
|
||||
}
|
||||
} else if (focusedItem) {
|
||||
// Just refocus the item
|
||||
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${focusedItem.name}"]`);
|
||||
if (itemElement) {
|
||||
itemElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pane mouse down - activate pane and start drag selection
|
||||
function handlePaneMouseDown(event, pane) {
|
||||
// Activate the pane
|
||||
setActivePane(pane);
|
||||
|
||||
// Start drag selection
|
||||
startDragSelection(event, pane);
|
||||
}
|
||||
|
||||
// Start drag selection
|
||||
function startDragSelection(event, pane) {
|
||||
// Only start drag selection if clicking on empty area (not on an item)
|
||||
if (event.target.closest('.selectable-item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the flag
|
||||
justFinishedDrag = false;
|
||||
|
||||
isDragSelecting = true;
|
||||
dragPane = pane;
|
||||
dragCtrlKey = event.ctrlKey || event.metaKey;
|
||||
|
||||
const contentArea = document.getElementById(pane + 'ContentArea');
|
||||
const rect = contentArea.getBoundingClientRect();
|
||||
|
||||
dragStartX = event.clientX - rect.left + contentArea.scrollLeft;
|
||||
dragStartY = event.clientY - rect.top + contentArea.scrollTop;
|
||||
dragCurrentX = dragStartX;
|
||||
dragCurrentY = dragStartY;
|
||||
|
||||
// Store initial selections when Ctrl is held
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
dragInitialSelections = new Set(selectedItems);
|
||||
|
||||
// Clear previous selections if not holding Ctrl
|
||||
if (!dragCtrlKey) {
|
||||
clearAllSelections(pane);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Update drag selection
|
||||
function updateDragSelection(event, pane) {
|
||||
if (!isDragSelecting || dragPane !== pane) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentArea = document.getElementById(pane + 'ContentArea');
|
||||
const rect = contentArea.getBoundingClientRect();
|
||||
|
||||
dragCurrentX = event.clientX - rect.left + contentArea.scrollLeft;
|
||||
dragCurrentY = event.clientY - rect.top + contentArea.scrollTop;
|
||||
|
||||
// Update selection rectangle
|
||||
updateSelectionRectangle(pane);
|
||||
|
||||
// Check which items intersect with selection rectangle
|
||||
updateItemSelection(pane);
|
||||
}
|
||||
|
||||
// End drag selection
|
||||
function endDragSelection(event, pane) {
|
||||
if (!isDragSelecting || dragPane !== pane) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this was actually a drag (mouse moved significantly)
|
||||
const dragDistance = Math.sqrt(
|
||||
Math.pow(dragCurrentX - dragStartX, 2) +
|
||||
Math.pow(dragCurrentY - dragStartY, 2)
|
||||
);
|
||||
|
||||
// Only treat as drag if moved more than 5 pixels
|
||||
if (dragDistance > 5) {
|
||||
justFinishedDrag = true;
|
||||
|
||||
// Final selection update to ensure all intersecting items are selected
|
||||
updateItemSelection(pane);
|
||||
|
||||
// Set focus to the last selected item
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
if (selectedItems.size > 0) {
|
||||
const items = getVisibleItems(pane);
|
||||
const selectedArray = Array.from(selectedItems);
|
||||
const lastSelected = selectedArray[selectedArray.length - 1];
|
||||
|
||||
// Find index of last selected item
|
||||
const itemIndex = items.findIndex(item => item.name === lastSelected);
|
||||
if (itemIndex !== -1) {
|
||||
setFocusedItem(pane, lastSelected, itemIndex);
|
||||
|
||||
// Set anchor to last selected item
|
||||
if (pane === 'left') {
|
||||
leftAnchorItem = { name: lastSelected, index: itemIndex };
|
||||
} else {
|
||||
rightAnchorItem = { name: lastSelected, index: itemIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the flag after a short delay to allow click event to check it
|
||||
setTimeout(() => {
|
||||
justFinishedDrag = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
isDragSelecting = false;
|
||||
dragPane = null;
|
||||
dragCtrlKey = false;
|
||||
dragInitialSelections.clear();
|
||||
|
||||
// Hide selection rectangle
|
||||
const selectionRect = document.getElementById(pane + 'SelectionRect');
|
||||
selectionRect.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update selection rectangle visual
|
||||
function updateSelectionRectangle(pane) {
|
||||
const selectionRect = document.getElementById(pane + 'SelectionRect');
|
||||
|
||||
const left = Math.min(dragStartX, dragCurrentX);
|
||||
const top = Math.min(dragStartY, dragCurrentY);
|
||||
const width = Math.abs(dragCurrentX - dragStartX);
|
||||
const height = Math.abs(dragCurrentY - dragStartY);
|
||||
|
||||
selectionRect.style.left = left + 'px';
|
||||
selectionRect.style.top = top + 'px';
|
||||
selectionRect.style.width = width + 'px';
|
||||
selectionRect.style.height = height + 'px';
|
||||
selectionRect.style.display = 'block';
|
||||
}
|
||||
|
||||
// Update item selection based on rectangle intersection
|
||||
function updateItemSelection(pane) {
|
||||
const contentArea = document.getElementById(pane + 'ContentArea');
|
||||
const items = contentArea.querySelectorAll('.selectable-item');
|
||||
|
||||
const selectionLeft = Math.min(dragStartX, dragCurrentX);
|
||||
const selectionTop = Math.min(dragStartY, dragCurrentY);
|
||||
const selectionRight = Math.max(dragStartX, dragCurrentX);
|
||||
const selectionBottom = Math.max(dragStartY, dragCurrentY);
|
||||
|
||||
// Track items that should be selected by drag
|
||||
const itemsInSelection = new Set();
|
||||
|
||||
items.forEach(item => {
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
const contentRect = contentArea.getBoundingClientRect();
|
||||
|
||||
const itemLeft = itemRect.left - contentRect.left + contentArea.scrollLeft;
|
||||
const itemTop = itemRect.top - contentRect.top + contentArea.scrollTop;
|
||||
const itemRight = itemLeft + itemRect.width;
|
||||
const itemBottom = itemTop + itemRect.height;
|
||||
|
||||
// Check if item intersects with selection rectangle
|
||||
const intersects = !(itemRight < selectionLeft ||
|
||||
itemLeft > selectionRight ||
|
||||
itemBottom < selectionTop ||
|
||||
itemTop > selectionBottom);
|
||||
|
||||
const itemName = item.dataset.itemName;
|
||||
|
||||
if (intersects) {
|
||||
itemsInSelection.add(itemName);
|
||||
}
|
||||
});
|
||||
|
||||
// Update selection based on whether Ctrl was held at drag start
|
||||
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
|
||||
|
||||
if (dragCtrlKey) {
|
||||
// Additive selection - restore initial selections and add drag selections
|
||||
clearAllSelections(pane);
|
||||
|
||||
// Re-add initial selections
|
||||
dragInitialSelections.forEach(itemName => {
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
});
|
||||
|
||||
// Add new drag selections
|
||||
itemsInSelection.forEach(itemName => {
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
});
|
||||
} else {
|
||||
// Replace selection - clear all and select only intersecting items
|
||||
clearAllSelections(pane);
|
||||
itemsInSelection.forEach(itemName => {
|
||||
selectedItems.add(itemName);
|
||||
addItemSelection(pane, itemName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add click handler to clear selection when clicking empty area
|
||||
function handleContentAreaClick(event, pane) {
|
||||
// Don't clear if we just finished a drag selection
|
||||
if (justFinishedDrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only clear selection if clicking on empty area (not on an item)
|
||||
if (!event.target.closest('.selectable-item') && !isDragSelecting) {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
clearAllSelections(pane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,334 @@
|
||||
# 新增功能说明
|
||||
|
||||
## 已实现功能
|
||||
|
||||
### 1. 空格键快速预览 (Quick Preview)
|
||||
|
||||
**功能描述:**
|
||||
- 选中文件/文件夹后按 `空格键` 可快速预览内容
|
||||
- 支持照片、视频、文件夹和文件的预览
|
||||
- 模态框形式展示,背景模糊效果
|
||||
- 再次按 `空格` 或 `ESC` 关闭预览
|
||||
|
||||
**使用方法:**
|
||||
1. 使用方向键或鼠标选择文件
|
||||
2. 按下 `空格键` 打开预览
|
||||
3. 查看预览内容和文件信息
|
||||
4. 按 `空格` 或 `ESC` 关闭
|
||||
|
||||
**技术实现:**
|
||||
- 组件:`src/components/FileManager/QuickPreview.vue`
|
||||
- 集成位置:`FilePane.vue` 中的键盘导航
|
||||
- 快捷键处理:`useKeyboardNav` composable
|
||||
|
||||
**当前状态:**
|
||||
- ✅ UI 框架完成
|
||||
- ✅ 键盘交互完成
|
||||
- ⚠️ 使用占位符显示(实际内容需要后端API)
|
||||
|
||||
**占位符内容:**
|
||||
- 📷 照片:显示"照片预览"占位符
|
||||
- 🎬 视频:显示"视频预览"占位符
|
||||
- 📁 文件夹:显示文件夹图标和模拟统计数据
|
||||
- 📄 文件:显示"暂不支持此类型文件预览"
|
||||
|
||||
---
|
||||
|
||||
### 2. 面包屑文件统计
|
||||
|
||||
**功能描述:**
|
||||
- 在面包屑导航栏实时显示当前文件夹的照片和视频数量
|
||||
- 只显示有内容的文件类型(照片数量为0则不显示)
|
||||
- 图标化展示,视觉直观
|
||||
|
||||
**显示格式:**
|
||||
```
|
||||
根目录 > 照片文件夹 > 2024 ✏️ | 🖼️ 42 🎬 15 [搜索框]
|
||||
```
|
||||
|
||||
**技术实现:**
|
||||
- 计算属性:`fileStats` in `FilePane.vue:312-324`
|
||||
- 实时统计:`photos`, `videos`, `folders`, `files`
|
||||
- 样式:蓝色照片图标,粉色视频图标
|
||||
|
||||
**当前状态:**
|
||||
- ✅ 完全实现
|
||||
- ✅ 实时更新
|
||||
- ✅ 响应式设计
|
||||
|
||||
---
|
||||
|
||||
### 3. 虚拟滚动基础架构
|
||||
|
||||
**功能描述:**
|
||||
- 为支持数万文件的文件夹浏览,实现虚拟滚动机制
|
||||
- 只渲染可见区域的文件项,大幅提升性能
|
||||
|
||||
**技术实现:**
|
||||
- Composable:`src/composables/fileManager/useVirtualScroll.ts`
|
||||
- 两种模式:
|
||||
- `useVirtualScroll`: 列表模式虚拟滚动
|
||||
- `useVirtualGrid`: 网格模式虚拟滚动
|
||||
|
||||
**列表模式虚拟滚动(useVirtualScroll):**
|
||||
```typescript
|
||||
const virtual = useVirtualScroll({
|
||||
items: computed(() => allItems), // 所有项目
|
||||
itemHeight: 40, // 每项固定高度
|
||||
containerHeight: ref(600), // 容器高度
|
||||
overscan: 5 // 上下预渲染项数
|
||||
})
|
||||
```
|
||||
|
||||
**网格模式虚拟滚动(useVirtualGrid):**
|
||||
```typescript
|
||||
const virtual = useVirtualGrid({
|
||||
items: computed(() => allItems),
|
||||
itemWidth: 100,
|
||||
itemHeight: 120,
|
||||
containerWidth: ref(800),
|
||||
containerHeight: ref(600),
|
||||
gap: 8,
|
||||
overscan: 2
|
||||
})
|
||||
```
|
||||
|
||||
**返回值:**
|
||||
- `visibleItems`: 当前可见的项目数组
|
||||
- `totalHeight`: 虚拟总高度(用于滚动条)
|
||||
- `offsetY`: 可见区域的Y轴偏移
|
||||
- `startIndex` / `endIndex`: 可见项目的索引范围
|
||||
- `onScroll`: 滚动事件处理函数
|
||||
|
||||
**当前状态:**
|
||||
- ✅ Composable 创建完成
|
||||
- ⚠️ 需要集成到 FilePane.vue
|
||||
- ⚠️ 需要调整现有的渲染逻辑
|
||||
|
||||
---
|
||||
|
||||
## 待完成任务
|
||||
|
||||
### 虚拟滚动集成
|
||||
|
||||
**需要做的修改:**
|
||||
|
||||
1. **在 FilePane.vue 中导入并使用虚拟滚动:**
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useVirtualScroll, useVirtualGrid } from '@/composables/fileManager/useVirtualScroll'
|
||||
|
||||
// 容器尺寸(需要使用 ResizeObserver 获取)
|
||||
const containerWidth = ref(800)
|
||||
const containerHeight = ref(600)
|
||||
|
||||
// 列表模式虚拟滚动
|
||||
const listVirtual = useVirtualScroll({
|
||||
items: computed(() => visibleItems.value),
|
||||
itemHeight: 40,
|
||||
containerHeight,
|
||||
overscan: 5
|
||||
})
|
||||
|
||||
// 网格模式虚拟滚动
|
||||
const gridVirtual = useVirtualGrid({
|
||||
items: computed(() => visibleItems.value),
|
||||
itemWidth: computed(() => {
|
||||
const baseSize = thumbnailSize === 'small' ? 80 : thumbnailSize === 'medium' ? 100 : 130
|
||||
return Math.round(baseSize * (zoomLevel.value / 100))
|
||||
}),
|
||||
itemHeight: computed(() => {
|
||||
const baseSize = thumbnailSize === 'small' ? 80 : thumbnailSize === 'medium' ? 100 : 130
|
||||
return Math.round(baseSize * (zoomLevel.value / 100))
|
||||
}),
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
gap: 8,
|
||||
overscan: 2
|
||||
})
|
||||
|
||||
// 根据视图模式选择虚拟滚动
|
||||
const currentVirtual = computed(() => {
|
||||
return viewMode === 'list' ? listVirtual : gridVirtual
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
2. **修改模板结构:**
|
||||
|
||||
```vue
|
||||
<!-- 列表视图 -->
|
||||
<div
|
||||
v-if="viewMode === 'list'"
|
||||
class="list-view"
|
||||
@scroll="listVirtual.onScroll"
|
||||
>
|
||||
<!-- 虚拟滚动容器 -->
|
||||
<div :style="{ height: listVirtual.totalHeight.value + 'px', position: 'relative' }">
|
||||
<!-- 可见项目容器 -->
|
||||
<div :style="{ transform: `translateY(${listVirtual.offsetY.value}px)` }">
|
||||
<div
|
||||
v-for="(item, index) in listVirtual.visibleItems.value"
|
||||
:key="item.name"
|
||||
class="list-item"
|
||||
>
|
||||
<!-- 项目内容 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网格视图类似 -->
|
||||
```
|
||||
|
||||
3. **添加容器尺寸监听:**
|
||||
|
||||
```typescript
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
|
||||
onMounted(() => {
|
||||
if (contentAreaRef.value) {
|
||||
useResizeObserver(contentAreaRef.value, (entries) => {
|
||||
const { width, height } = entries[0].contentRect
|
||||
containerWidth.value = width
|
||||
containerHeight.value = height
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试指南
|
||||
|
||||
### 测试空格键预览
|
||||
|
||||
1. 启动开发服务器:`pnpm run dev`
|
||||
2. 访问文件夹视图页面
|
||||
3. 使用方向键选择任意文件
|
||||
4. 按下 `空格键`
|
||||
5. 验证预览窗口弹出
|
||||
6. 再次按 `空格` 或 `ESC` 关闭
|
||||
|
||||
### 测试面包屑统计
|
||||
|
||||
1. 浏览到包含照片和视频的文件夹
|
||||
2. 观察面包屑右侧的统计信息
|
||||
3. 进入不同文件夹,验证数字实时更新
|
||||
4. 进入空文件夹,验证统计标签消失
|
||||
|
||||
### 测试虚拟滚动(集成后)
|
||||
|
||||
1. 创建包含大量文件的模拟数据(如10000个文件)
|
||||
2. 浏览到该文件夹
|
||||
3. 观察滚动性能
|
||||
4. 使用开发者工具查看DOM元素数量
|
||||
5. 验证只有可见区域的元素被渲染
|
||||
|
||||
---
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 依赖安装
|
||||
|
||||
```bash
|
||||
pnpm install vue-virtual-scroller@next
|
||||
```
|
||||
|
||||
### 新增文件
|
||||
|
||||
1. `src/components/FileManager/QuickPreview.vue` - 快速预览组件
|
||||
2. `src/composables/fileManager/useVirtualScroll.ts` - 虚拟滚动逻辑
|
||||
|
||||
### 修改文件
|
||||
|
||||
1. `src/components/FileManager/FilePane.vue`
|
||||
- 添加预览组件
|
||||
- 添加文件统计显示
|
||||
- 修改键盘导航处理
|
||||
|
||||
2. `src/composables/fileManager/useKeyboardNav.ts`
|
||||
- 修改 `onSpace` 回调类型签名
|
||||
|
||||
### 样式说明
|
||||
|
||||
**预览窗口:**
|
||||
- 背景:半透明黑色 + 模糊效果
|
||||
- 最大宽度:900px
|
||||
- 动画:淡入淡出 + 缩放
|
||||
|
||||
**文件统计:**
|
||||
- 位置:面包屑右侧,搜索框左侧
|
||||
- 样式:白色卡片,轻微阴影
|
||||
- 颜色:照片蓝色 (#3b82f6),视频粉色 (#ec4899)
|
||||
|
||||
---
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
### 短期(功能完善)
|
||||
|
||||
1. **虚拟滚动集成**
|
||||
- 集成到 FilePane.vue
|
||||
- 处理选择状态的虚拟化
|
||||
- 优化拖拽选择与虚拟滚动的兼容性
|
||||
|
||||
2. **预览功能增强**
|
||||
- 接入真实的文件预览API
|
||||
- 支持图片缩放、旋转
|
||||
- 支持视频播放控制
|
||||
- 添加文件元数据展示(EXIF)
|
||||
|
||||
3. **性能优化**
|
||||
- 预览组件懒加载
|
||||
- 文件统计缓存
|
||||
- 虚拟滚动性能调优
|
||||
|
||||
### 中期(用户体验)
|
||||
|
||||
1. **键盘快捷键**
|
||||
- `←/→` 在预览中切换上/下一个文件
|
||||
- `Ctrl+Space` 添加到收藏
|
||||
- `Shift+Space` 快速选择
|
||||
|
||||
2. **视觉优化**
|
||||
- 添加加载动画
|
||||
- 优化过渡效果
|
||||
- 支持深色模式
|
||||
|
||||
3. **功能扩展**
|
||||
- 支持拖拽到预览窗口
|
||||
- 预览窗口支持复制/分享
|
||||
- 文件统计支持更多类型
|
||||
|
||||
### 长期(架构优化)
|
||||
|
||||
1. **Worker 优化**
|
||||
- 文件统计在 Worker 中计算
|
||||
- 大文件预览使用流式加载
|
||||
|
||||
2. **缓存策略**
|
||||
- 预览缩略图缓存
|
||||
- 文件夹统计缓存
|
||||
- IndexedDB 持久化
|
||||
|
||||
3. **可访问性**
|
||||
- 屏幕阅读器支持
|
||||
- 键盘导航优化
|
||||
- 高对比度模式
|
||||
|
||||
---
|
||||
|
||||
## 已知问题
|
||||
|
||||
1. ❌ 预览功能使用占位符,需要接入真实API
|
||||
2. ❌ 虚拟滚动未集成到主组件
|
||||
3. ⚠️ 文件统计未缓存,大文件夹可能有性能问题
|
||||
4. ⚠️ 预览窗口不支持键盘切换文件
|
||||
|
||||
---
|
||||
|
||||
## 联系与反馈
|
||||
|
||||
如有问题或建议,请参考项目 README 或提交 Issue。
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
|
||||
|
||||
|
||||
## 关于视图
|
||||
时间线视图是为了展示内容,以一种更加友好和集中的方式来展示这些信息。
|
||||
而文件夹视图是为了提供一种更加熟悉的方式来展示,并且还原对应的照片文件在文件夹中的位置。
|
||||
|
||||
他们二者是分开的,以不同的目的来呈现的,用户可以通过某个照片的预览界面,直接进入到对应的文件夹页面,也能通过文件夹界面通过点击的方式
|
||||
进入到预览界面,二者是互通的。
|
||||
@@ -76,6 +76,8 @@
|
||||
pane-id="left"
|
||||
:view-mode="paneConfigs.left.viewMode"
|
||||
:thumbnail-size="paneConfigs.left.thumbnailSize"
|
||||
:sort-options="paneConfigs.left.sortOptions"
|
||||
:filter-options="paneConfigs.left.filterOptions"
|
||||
:is-active="activePane === 'left'"
|
||||
@activate="activePane = 'left'"
|
||||
/>
|
||||
@@ -92,6 +94,8 @@
|
||||
pane-id="right"
|
||||
:view-mode="paneConfigs.right.viewMode"
|
||||
:thumbnail-size="paneConfigs.right.thumbnailSize"
|
||||
:sort-options="paneConfigs.right.sortOptions"
|
||||
:filter-options="paneConfigs.right.filterOptions"
|
||||
:is-active="activePane === 'right'"
|
||||
@activate="activePane = 'right'"
|
||||
/>
|
||||
@@ -102,7 +106,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import FilePane from './FilePane.vue'
|
||||
import type { ViewMode, ThumbnailSize, LayoutMode, PaneId } from './types'
|
||||
import type { ViewMode, ThumbnailSize, LayoutMode, PaneId, SortOptions, FilterOptions } from './types'
|
||||
|
||||
// FilePane refs
|
||||
const leftPaneRef = ref<InstanceType<typeof FilePane>>()
|
||||
@@ -112,11 +116,27 @@ const rightPaneRef = ref<InstanceType<typeof FilePane>>()
|
||||
const paneConfigs = ref({
|
||||
left: {
|
||||
viewMode: 'grid' as ViewMode,
|
||||
thumbnailSize: 'medium' as ThumbnailSize
|
||||
thumbnailSize: 'medium' as ThumbnailSize,
|
||||
sortOptions: {
|
||||
field: 'name',
|
||||
order: 'asc'
|
||||
} as SortOptions,
|
||||
filterOptions: {
|
||||
nameQuery: '',
|
||||
fileType: 'all'
|
||||
} as FilterOptions
|
||||
},
|
||||
right: {
|
||||
viewMode: 'grid' as ViewMode,
|
||||
thumbnailSize: 'medium' as ThumbnailSize
|
||||
thumbnailSize: 'medium' as ThumbnailSize,
|
||||
sortOptions: {
|
||||
field: 'name',
|
||||
order: 'asc'
|
||||
} as SortOptions,
|
||||
filterOptions: {
|
||||
nameQuery: '',
|
||||
fileType: 'all'
|
||||
} as FilterOptions
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,56 +3,75 @@
|
||||
<!-- Top indicator -->
|
||||
<div v-if="isActive" class="active-indicator"></div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div class="breadcrumb" @click.stop="$emit('activate')">
|
||||
<div v-if="!pathEditMode" class="breadcrumb-content">
|
||||
<span
|
||||
v-for="(segment, index) in currentPath"
|
||||
:key="index"
|
||||
:class="['breadcrumb-segment']"
|
||||
>
|
||||
<!-- Breadcrumb and Search Bar -->
|
||||
<div class="breadcrumb-container" @click.stop="$emit('activate')">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div class="breadcrumb">
|
||||
<div v-if="!pathEditMode" class="breadcrumb-content">
|
||||
<span
|
||||
:class="['breadcrumb-item', { current: index === currentPath.length - 1 }]"
|
||||
@click.stop="navigateToIndex(index)"
|
||||
v-for="(segment, index) in currentPath"
|
||||
:key="index"
|
||||
:class="['breadcrumb-segment']"
|
||||
>
|
||||
{{ segment }}
|
||||
<span
|
||||
:class="['breadcrumb-item', { current: index === currentPath.length - 1 }]"
|
||||
@click.stop="navigateToIndex(index)"
|
||||
>
|
||||
{{ segment }}
|
||||
</span>
|
||||
<span
|
||||
v-if="index < currentPath.length - 1"
|
||||
:class="['separator', { active: breadcrumbDropdown.index === index }]"
|
||||
@click.stop="toggleBreadcrumbDropdown(index)"
|
||||
>
|
||||
›
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="index < currentPath.length - 1"
|
||||
:class="['separator', { active: breadcrumbDropdown.index === index }]"
|
||||
@click.stop="toggleBreadcrumbDropdown(index)"
|
||||
>
|
||||
›
|
||||
</span>
|
||||
</span>
|
||||
<button class="path-edit-btn" @click.stop="enterPathEditMode" title="编辑路径">
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="path-edit-container">
|
||||
<input
|
||||
ref="pathInputRef"
|
||||
v-model="pathEditValue"
|
||||
class="path-input"
|
||||
@blur="exitPathEditMode"
|
||||
@keydown.enter="applyPathEdit"
|
||||
@keydown.esc="cancelPathEdit"
|
||||
@click.stop
|
||||
/>
|
||||
<button class="path-edit-confirm" @click.stop="applyPathEdit" title="确认">✓</button>
|
||||
<button class="path-edit-cancel" @click.stop="cancelPathEdit" title="取消">✗</button>
|
||||
<button class="path-edit-btn" @click.stop="enterPathEditMode" title="编辑路径">
|
||||
✏️
|
||||
</button>
|
||||
|
||||
<!-- File Stats -->
|
||||
<div class="file-stats">
|
||||
<span v-if="fileStats.photos > 0" class="stat-item stat-photo" title="照片">
|
||||
🖼️ {{ fileStats.photos }}
|
||||
</span>
|
||||
<span v-if="fileStats.videos > 0" class="stat-item stat-video" title="视频">
|
||||
🎬 {{ fileStats.videos }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="path-edit-container">
|
||||
<input
|
||||
ref="pathInputRef"
|
||||
v-model="pathEditValue"
|
||||
class="path-input"
|
||||
@blur="exitPathEditMode"
|
||||
@keydown.enter="applyPathEdit"
|
||||
@keydown.esc="cancelPathEdit"
|
||||
@click.stop
|
||||
/>
|
||||
<button class="path-edit-confirm" @click.stop="applyPathEdit" title="确认">✓</button>
|
||||
<button class="path-edit-cancel" @click.stop="cancelPathEdit" title="取消">✗</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Input -->
|
||||
<!-- Search Box (always visible) -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
v-model="props.filterOptions.nameQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="搜索..."
|
||||
@click.stop
|
||||
/>
|
||||
<button v-if="searchQuery" class="search-clear" @click.stop="searchQuery = ''">×</button>
|
||||
<button
|
||||
v-if="props.filterOptions.nameQuery"
|
||||
class="search-clear"
|
||||
@click.stop="props.filterOptions.nameQuery = ''"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +101,7 @@
|
||||
}]"
|
||||
:data-item-name="item.name"
|
||||
:data-item-index="index"
|
||||
:data-item-type="item.type"
|
||||
:draggable="true"
|
||||
@click="handleItemClick($event, item.name, index)"
|
||||
@dblclick="handleItemDoubleClick(item)"
|
||||
@@ -103,6 +123,30 @@
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else class="file-list">
|
||||
<!-- List Header -->
|
||||
<div class="list-header">
|
||||
<div class="header-cell icon-cell"></div>
|
||||
<div class="header-cell name-cell" @click="handleHeaderSort('name')">
|
||||
<span>名称</span>
|
||||
<span v-if="props.sortOptions.field === 'name'" class="sort-indicator">
|
||||
{{ props.sortOptions.order === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-cell size-cell" @click="handleHeaderSort('size')">
|
||||
<span>大小</span>
|
||||
<span v-if="props.sortOptions.field === 'size'" class="sort-indicator">
|
||||
{{ props.sortOptions.order === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-cell date-cell" @click="handleHeaderSort('date')">
|
||||
<span>修改日期</span>
|
||||
<span v-if="props.sortOptions.field === 'date'" class="sort-indicator">
|
||||
{{ props.sortOptions.order === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List Items -->
|
||||
<div
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="item.name"
|
||||
@@ -114,6 +158,7 @@
|
||||
}]"
|
||||
:data-item-name="item.name"
|
||||
:data-item-index="index"
|
||||
:data-item-type="item.type"
|
||||
:draggable="true"
|
||||
@click="handleItemClick($event, item.name, index)"
|
||||
@dblclick="handleItemDoubleClick(item)"
|
||||
@@ -179,6 +224,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Quick Preview -->
|
||||
<QuickPreview
|
||||
:visible="quickPreview.visible"
|
||||
:item="quickPreview.item"
|
||||
@close="closeQuickPreview"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -193,6 +245,7 @@ import { mockFolderStructure as originalMockData } from './mockData'
|
||||
import type { FileItem, ViewMode, ThumbnailSize, PaneId } from './types'
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
import Tooltip from './Tooltip.vue'
|
||||
import QuickPreview from './QuickPreview.vue'
|
||||
|
||||
// 将 mockData 转换为响应式对象(全局共享)
|
||||
const mockFolderStructure = reactive(originalMockData)
|
||||
@@ -202,6 +255,14 @@ const props = defineProps<{
|
||||
viewMode: ViewMode
|
||||
thumbnailSize: ThumbnailSize
|
||||
isActive: boolean
|
||||
sortOptions: {
|
||||
field: string
|
||||
order: string
|
||||
}
|
||||
filterOptions: {
|
||||
nameQuery: string
|
||||
fileType: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -213,7 +274,6 @@ const currentPath = ref<string[]>(['Home'])
|
||||
const contentAreaRef = ref<HTMLElement>()
|
||||
const tooltipRef = ref<InstanceType<typeof Tooltip>>()
|
||||
const pathInputRef = ref<HTMLInputElement>()
|
||||
const searchQuery = ref('')
|
||||
const pathEditMode = ref(false)
|
||||
const pathEditValue = ref('')
|
||||
const zoomLevel = ref(100) // 缩放级别:50-200%
|
||||
@@ -239,6 +299,12 @@ const breadcrumbDropdown = ref({
|
||||
folders: [] as string[]
|
||||
})
|
||||
|
||||
// Quick Preview state
|
||||
const quickPreview = ref({
|
||||
visible: false,
|
||||
item: null as FileItem | null
|
||||
})
|
||||
|
||||
// Composables
|
||||
const selection = useFileSelection()
|
||||
const dragSelectionLogic = useDragSelection()
|
||||
@@ -253,31 +319,133 @@ const currentFolder = computed(() => {
|
||||
return getFolderByPath(mockFolderStructure, currentPath.value)
|
||||
})
|
||||
|
||||
// 总项目数(未过滤)
|
||||
const totalItemsCount = computed(() => {
|
||||
if (!currentFolder.value) return 0
|
||||
return Object.keys(currentFolder.value).length
|
||||
})
|
||||
|
||||
// 文件类型统计
|
||||
const fileStats = computed(() => {
|
||||
if (!currentFolder.value) {
|
||||
return { photos: 0, videos: 0, folders: 0, files: 0 }
|
||||
}
|
||||
|
||||
const items = Object.values(currentFolder.value)
|
||||
return {
|
||||
photos: items.filter(item => item.type === 'photo').length,
|
||||
videos: items.filter(item => item.type === 'video').length,
|
||||
folders: items.filter(item => item.type === 'folder').length,
|
||||
files: items.filter(item => item.type === 'file').length
|
||||
}
|
||||
})
|
||||
|
||||
// 表头排序点击处理
|
||||
function handleHeaderSort(field: string) {
|
||||
if (props.sortOptions.field === field) {
|
||||
// 如果点击的是当前排序字段,切换顺序
|
||||
const current = props.sortOptions.order
|
||||
props.sortOptions.order = current === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
// 如果点击的是新字段,设置为该字段并默认升序
|
||||
props.sortOptions.field = field as any
|
||||
props.sortOptions.order = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
if (!currentFolder.value) return []
|
||||
|
||||
let items = Object.values(currentFolder.value).sort((a, b) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
let items = Object.values(currentFolder.value)
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
// 文件名搜索过滤
|
||||
if (props.filterOptions.nameQuery.trim()) {
|
||||
const query = props.filterOptions.nameQuery.toLowerCase()
|
||||
items = items.filter(item => item.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return items
|
||||
// 排序
|
||||
return sortItems(items, props.sortOptions.field, props.sortOptions.order)
|
||||
})
|
||||
|
||||
// 排序函数
|
||||
function sortItems(items: FileItem[], field: string, order: string): FileItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
// 文件夹始终优先(除非按类型排序)
|
||||
if (field !== 'type') {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1
|
||||
}
|
||||
|
||||
let comparison = 0
|
||||
|
||||
switch (field) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name, 'zh-CN')
|
||||
break
|
||||
case 'extension': {
|
||||
const extA = getFileExtension(a.name)
|
||||
const extB = getFileExtension(b.name)
|
||||
comparison = extA.localeCompare(extB)
|
||||
// 如果扩展名相同,按文件名排序
|
||||
if (comparison === 0) {
|
||||
comparison = a.name.localeCompare(b.name, 'zh-CN')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'date':
|
||||
comparison = (a.date || '').localeCompare(b.date || '')
|
||||
break
|
||||
case 'size': {
|
||||
const sizeA = parseSizeToBytes(a.size || '0')
|
||||
const sizeB = parseSizeToBytes(b.size || '0')
|
||||
comparison = sizeA - sizeB
|
||||
break
|
||||
}
|
||||
case 'type':
|
||||
comparison = a.type.localeCompare(b.type)
|
||||
// 如果类型相同,按文件名排序
|
||||
if (comparison === 0) {
|
||||
comparison = a.name.localeCompare(b.name, 'zh-CN')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return order === 'desc' ? -comparison : comparison
|
||||
})
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
function getFileExtension(filename: string): string {
|
||||
const lastDot = filename.lastIndexOf('.')
|
||||
if (lastDot === -1 || lastDot === 0) return ''
|
||||
return filename.substring(lastDot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
// 解析大小字符串为字节数
|
||||
function parseSizeToBytes(sizeStr: string): number {
|
||||
const match = sizeStr.match(/^([\d.]+)\s*(B|KB|MB|GB)?$/i)
|
||||
if (!match) return 0
|
||||
|
||||
const value = parseFloat(match[1])
|
||||
const unit = (match[2] || 'B').toUpperCase()
|
||||
|
||||
switch (unit) {
|
||||
case 'GB': return value * 1024 * 1024 * 1024
|
||||
case 'MB': return value * 1024 * 1024
|
||||
case 'KB': return value * 1024
|
||||
case 'B': return value
|
||||
default: return value
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
function highlightText(text: string): string {
|
||||
if (!searchQuery.value.trim()) {
|
||||
if (!props.filterOptions.nameQuery.trim()) {
|
||||
return text
|
||||
}
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
const query = props.filterOptions.nameQuery.trim()
|
||||
const regex = new RegExp(`(${query})`, 'gi')
|
||||
return text.replace(regex, '<mark>$1</mark>')
|
||||
}
|
||||
@@ -286,7 +454,6 @@ function navigateToIndex(index: number) {
|
||||
currentPath.value = currentPath.value.slice(0, index + 1)
|
||||
addToHistory(currentPath.value)
|
||||
selection.clearSelection()
|
||||
searchQuery.value = ''
|
||||
breadcrumbDropdown.value.visible = false
|
||||
}
|
||||
|
||||
@@ -310,7 +477,6 @@ function goBack() {
|
||||
historyIndex.value--
|
||||
currentPath.value = [...history.value[historyIndex.value]]
|
||||
selection.clearSelection()
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +485,6 @@ function goForward() {
|
||||
historyIndex.value++
|
||||
currentPath.value = [...history.value[historyIndex.value]]
|
||||
selection.clearSelection()
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +541,6 @@ function navigateToBreadcrumbFolder(folderName: string) {
|
||||
currentPath.value = newPath
|
||||
addToHistory(currentPath.value)
|
||||
selection.clearSelection()
|
||||
searchQuery.value = ''
|
||||
breadcrumbDropdown.value.visible = false
|
||||
}
|
||||
|
||||
@@ -426,7 +590,6 @@ function applyPathEdit() {
|
||||
currentPath.value = segments
|
||||
addToHistory(currentPath.value)
|
||||
selection.clearSelection()
|
||||
searchQuery.value = ''
|
||||
pathEditMode.value = false
|
||||
}
|
||||
|
||||
@@ -439,7 +602,6 @@ function navigateToFolder(folderName: string) {
|
||||
currentPath.value.push(folderName)
|
||||
addToHistory(currentPath.value)
|
||||
selection.clearSelection()
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
function isFocused(itemName: string): boolean {
|
||||
@@ -914,7 +1076,7 @@ const gridItemSize = computed(() => {
|
||||
})
|
||||
|
||||
// 监听搜索变化清除选择
|
||||
watch(searchQuery, () => {
|
||||
watch(() => props.filterOptions.nameQuery, () => {
|
||||
selection.clearSelection()
|
||||
})
|
||||
|
||||
@@ -981,10 +1143,24 @@ useKeyboardNav({
|
||||
}
|
||||
}
|
||||
},
|
||||
onSpace: () => {
|
||||
onSpace: (event: KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
// 如果预览已打开,关闭预览
|
||||
if (quickPreview.value.visible) {
|
||||
quickPreview.value.visible = false
|
||||
quickPreview.value.item = null
|
||||
return
|
||||
}
|
||||
|
||||
// 打开预览
|
||||
const focused = selection.focusedItem.value
|
||||
if (focused) {
|
||||
selection.toggleItemSelection(focused.name, focused.index)
|
||||
const item = visibleItems.value[focused.index]
|
||||
if (item) {
|
||||
quickPreview.value.visible = true
|
||||
quickPreview.value.item = item
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectAll: () => {
|
||||
@@ -993,6 +1169,12 @@ useKeyboardNav({
|
||||
getGridColumns
|
||||
})
|
||||
|
||||
// 关闭快速预览
|
||||
function closeQuickPreview() {
|
||||
quickPreview.value.visible = false
|
||||
quickPreview.value.item = null
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
goBack,
|
||||
@@ -1036,23 +1218,30 @@ defineExpose({
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
/* Breadcrumb Container */
|
||||
.breadcrumb-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
background: #f9fafb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.breadcrumb:hover {
|
||||
.breadcrumb-container:hover {
|
||||
background: rgba(59, 130, 246, 0.03);
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.breadcrumb-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1080,6 +1269,36 @@ defineExpose({
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* File Stats */
|
||||
.file-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: 16px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stat-photo {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-video {
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
.path-edit-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1214,11 +1433,12 @@ defineExpose({
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Search Box */
|
||||
/* Search Box in Breadcrumb */
|
||||
.search-box {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 200px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@@ -1353,6 +1573,24 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 网格视图文件类型图标颜色 */
|
||||
.file-item[data-item-type="folder"] .file-icon {
|
||||
color: #f59e0b;
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
.file-item[data-item-type="photo"] .file-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.file-item[data-item-type="video"] .file-icon {
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
.file-item[data-item-type="file"] .file-icon {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
@@ -1386,27 +1624,79 @@ defineExpose({
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* List Header */
|
||||
.list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 100px 140px;
|
||||
align-items: center;
|
||||
padding: 10px 8px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.header-cell:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-cell.icon-cell {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
color: #3b82f6;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 100px 140px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
min-height: 32px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* 斑马条纹 */
|
||||
.list-item:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #e5e7eb;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
background: #cce8ff;
|
||||
border-color: #99d1ff;
|
||||
background: #dbeafe;
|
||||
border-bottom-color: #93c5fd;
|
||||
}
|
||||
|
||||
.list-item.selected:nth-child(even) {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.list-item.drop-target {
|
||||
@@ -1426,6 +1716,24 @@ defineExpose({
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 文件类型图标颜色 */
|
||||
.list-item[data-item-type="folder"] .list-icon {
|
||||
color: #f59e0b;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.list-item[data-item-type="photo"] .list-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.list-item[data-item-type="video"] .list-icon {
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
.list-item[data-item-type="file"] .list-icon {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.list-name {
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="preview-fade">
|
||||
<div v-if="visible" class="quick-preview-overlay" @click="handleClose" @keydown.space.prevent>
|
||||
<div class="preview-container" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="preview-header">
|
||||
<div class="preview-title">
|
||||
<span class="file-icon">{{ getFileIcon(item?.type) }}</span>
|
||||
<span class="file-name">{{ item?.name || '预览' }}</span>
|
||||
</div>
|
||||
<button class="btn-close" @click="handleClose" title="关闭 (ESC)">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="preview-content">
|
||||
<div v-if="item?.type === 'photo'" class="preview-photo">
|
||||
<div class="placeholder-image">📷 照片预览</div>
|
||||
<p class="placeholder-text">这里将显示照片内容</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item?.type === 'video'" class="preview-video">
|
||||
<div class="placeholder-video">🎬 视频预览</div>
|
||||
<p class="placeholder-text">这里将显示视频播放器</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item?.type === 'folder'" class="preview-folder">
|
||||
<div class="folder-icon-large">📁</div>
|
||||
<p class="folder-name">{{ item?.name }}</p>
|
||||
<div class="folder-info">
|
||||
<div class="info-item">
|
||||
<span class="label">包含:</span>
|
||||
<span class="value">{{ mockFolderStats.files }} 个文件</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">大小:</span>
|
||||
<span class="value">{{ mockFolderStats.size }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">修改时间:</span>
|
||||
<span class="value">{{ item?.date || '2024-01-01' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="preview-file">
|
||||
<div class="placeholder-file">📄 文件预览</div>
|
||||
<p class="placeholder-text">暂不支持此类型文件预览</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="preview-footer">
|
||||
<div class="file-info">
|
||||
<span class="info-label">大小:</span>
|
||||
<span class="info-value">{{ item?.size || '-' }}</span>
|
||||
<span class="info-separator">|</span>
|
||||
<span class="info-label">修改日期:</span>
|
||||
<span class="info-value">{{ item?.date || '-' }}</span>
|
||||
</div>
|
||||
<div class="keyboard-hint">
|
||||
<span>按 <kbd>空格</kbd> 或 <kbd>ESC</kbd> 关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import type { FileItem } from './types'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
item: FileItem | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// 模拟文件夹统计数据
|
||||
const mockFolderStats = ref({
|
||||
files: 42,
|
||||
size: '2.3 GB'
|
||||
})
|
||||
|
||||
function getFileIcon(type?: string) {
|
||||
switch (type) {
|
||||
case 'folder':
|
||||
return '📁'
|
||||
case 'photo':
|
||||
return '🖼️'
|
||||
case 'video':
|
||||
return '🎬'
|
||||
default:
|
||||
return '📄'
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 键盘事件处理
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!props.visible) return
|
||||
|
||||
// 只处理 ESC 键,空格键由父组件处理
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
// 当预览打开时,阻止body滚动
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.quick-preview-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
max-width: 900px;
|
||||
max-height: 90vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-photo,
|
||||
.preview-video,
|
||||
.preview-folder,
|
||||
.preview-file {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.placeholder-image,
|
||||
.placeholder-video,
|
||||
.placeholder-file {
|
||||
font-size: 80px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Folder preview */
|
||||
.folder-icon-large {
|
||||
font-size: 120px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.folder-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.preview-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-separator {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.keyboard-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 2px 6px;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.preview-fade-enter-active,
|
||||
.preview-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.preview-fade-enter-active .preview-container,
|
||||
.preview-fade-leave-active .preview-container {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.preview-fade-enter-from,
|
||||
.preview-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.preview-fade-enter-from .preview-container,
|
||||
.preview-fade-leave-to .preview-container {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,20 @@ export type ThumbnailSize = 'small' | 'medium' | 'large'
|
||||
export type LayoutMode = 'single' | 'horizontal' | 'vertical'
|
||||
export type PaneId = 'left' | 'right'
|
||||
|
||||
// 排序和筛选相关类型
|
||||
export type SortField = 'name' | 'extension' | 'date' | 'size' | 'type'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface SortOptions {
|
||||
field: SortField
|
||||
order: SortOrder
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
nameQuery: string
|
||||
fileType: 'all' | 'photo' | 'video' | 'folder' | 'file'
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
selectedItems: Set<string>
|
||||
focusedItem: { name: string; index: number } | null
|
||||
|
||||
@@ -10,7 +10,7 @@ interface UseKeyboardNavOptions {
|
||||
selectedItems: Ref<Set<string>>
|
||||
onNavigate: (newIndex: number, event: KeyboardEvent) => void
|
||||
onEnter: () => void
|
||||
onSpace: () => void
|
||||
onSpace: (event: KeyboardEvent) => void
|
||||
onSelectAll: () => void
|
||||
getGridColumns: () => number
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function useKeyboardNav(options: UseKeyboardNavOptions) {
|
||||
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
onSpace()
|
||||
onSpace(event)
|
||||
break
|
||||
|
||||
case 'a':
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ref, computed, watch, type Ref } from 'vue'
|
||||
|
||||
interface UseVirtualScrollOptions {
|
||||
items: Ref<any[]>
|
||||
itemHeight: number // 固定高度(列表模式)
|
||||
containerHeight: Ref<number>
|
||||
overscan?: number // 上下额外渲染的项目数
|
||||
}
|
||||
|
||||
export function useVirtualScroll(options: UseVirtualScrollOptions) {
|
||||
const { items, itemHeight, containerHeight, overscan = 5 } = options
|
||||
|
||||
const scrollTop = ref(0)
|
||||
|
||||
// 可视区域可以显示的项目数
|
||||
const visibleCount = computed(() => {
|
||||
return Math.ceil(containerHeight.value / itemHeight) + overscan * 2
|
||||
})
|
||||
|
||||
// 开始索引
|
||||
const startIndex = computed(() => {
|
||||
const index = Math.floor(scrollTop.value / itemHeight) - overscan
|
||||
return Math.max(0, index)
|
||||
})
|
||||
|
||||
// 结束索引
|
||||
const endIndex = computed(() => {
|
||||
return Math.min(items.value.length, startIndex.value + visibleCount.value)
|
||||
})
|
||||
|
||||
// 可见项目
|
||||
const visibleItems = computed(() => {
|
||||
return items.value.slice(startIndex.value, endIndex.value)
|
||||
})
|
||||
|
||||
// 总高度
|
||||
const totalHeight = computed(() => {
|
||||
return items.value.length * itemHeight
|
||||
})
|
||||
|
||||
// 偏移量
|
||||
const offsetY = computed(() => {
|
||||
return startIndex.value * itemHeight
|
||||
})
|
||||
|
||||
// 滚动处理
|
||||
function onScroll(event: Event) {
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
}
|
||||
|
||||
return {
|
||||
visibleItems,
|
||||
totalHeight,
|
||||
offsetY,
|
||||
startIndex,
|
||||
endIndex,
|
||||
onScroll,
|
||||
scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
// 网格虚拟滚动(更复杂,需要计算列数)
|
||||
interface UseVirtualGridOptions {
|
||||
items: Ref<any[]>
|
||||
itemWidth: number
|
||||
itemHeight: number
|
||||
containerWidth: Ref<number>
|
||||
containerHeight: Ref<number>
|
||||
gap?: number
|
||||
overscan?: number
|
||||
}
|
||||
|
||||
export function useVirtualGrid(options: UseVirtualGridOptions) {
|
||||
const {
|
||||
items,
|
||||
itemWidth,
|
||||
itemHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
gap = 8,
|
||||
overscan = 2
|
||||
} = options
|
||||
|
||||
const scrollTop = ref(0)
|
||||
|
||||
// 每行的列数
|
||||
const columns = computed(() => {
|
||||
const availableWidth = containerWidth.value - gap
|
||||
return Math.max(1, Math.floor(availableWidth / (itemWidth + gap)))
|
||||
})
|
||||
|
||||
// 总行数
|
||||
const rowCount = computed(() => {
|
||||
return Math.ceil(items.value.length / columns.value)
|
||||
})
|
||||
|
||||
// 行高(包含间距)
|
||||
const rowHeight = computed(() => {
|
||||
return itemHeight + gap
|
||||
})
|
||||
|
||||
// 可见行数
|
||||
const visibleRowCount = computed(() => {
|
||||
return Math.ceil(containerHeight.value / rowHeight.value) + overscan * 2
|
||||
})
|
||||
|
||||
// 开始行索引
|
||||
const startRow = computed(() => {
|
||||
const row = Math.floor(scrollTop.value / rowHeight.value) - overscan
|
||||
return Math.max(0, row)
|
||||
})
|
||||
|
||||
// 结束行索引
|
||||
const endRow = computed(() => {
|
||||
return Math.min(rowCount.value, startRow.value + visibleRowCount.value)
|
||||
})
|
||||
|
||||
// 开始项目索引
|
||||
const startIndex = computed(() => {
|
||||
return startRow.value * columns.value
|
||||
})
|
||||
|
||||
// 结束项目索引
|
||||
const endIndex = computed(() => {
|
||||
return Math.min(items.value.length, endRow.value * columns.value)
|
||||
})
|
||||
|
||||
// 可见项目
|
||||
const visibleItems = computed(() => {
|
||||
return items.value.slice(startIndex.value, endIndex.value)
|
||||
})
|
||||
|
||||
// 总高度
|
||||
const totalHeight = computed(() => {
|
||||
return rowCount.value * rowHeight.value
|
||||
})
|
||||
|
||||
// 偏移量
|
||||
const offsetY = computed(() => {
|
||||
return startRow.value * rowHeight.value
|
||||
})
|
||||
|
||||
// 滚动处理
|
||||
function onScroll(event: Event) {
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
}
|
||||
|
||||
return {
|
||||
visibleItems,
|
||||
totalHeight,
|
||||
offsetY,
|
||||
startIndex,
|
||||
endIndex,
|
||||
columns,
|
||||
rowCount,
|
||||
onScroll,
|
||||
scrollTop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
# 后端API建议
|
||||
|
||||
## 当前API使用体验
|
||||
|
||||
基于对 `timeline.http` 和 `photo.http` API文档的分析,当前API设计整体合理,能够满足照片时间线展示的需求。以下是一些建议改进点:
|
||||
|
||||
## 建议改进的API设计
|
||||
|
||||
### 1. 照片详情API缺失
|
||||
|
||||
**问题**:
|
||||
- 目前只有获取照片文件的接口(`/api/v1/photo/{hash}`),缺少获取照片详细信息的API
|
||||
- 前端需要照片的元数据信息(如拍摄地点、设备信息、标签等)用于详情展示
|
||||
|
||||
**建议**:
|
||||
```http
|
||||
GET /api/v1/photo/{hash}/metadata
|
||||
```
|
||||
返回格式:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"hash": "abc123...",
|
||||
"filename": "IMG_1234.jpg",
|
||||
"fileSize": 1024000,
|
||||
"takenAt": "2024-10-05T10:30:00",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"location": {
|
||||
"latitude": 39.9042,
|
||||
"longitude": 116.4074,
|
||||
"address": "北京市"
|
||||
},
|
||||
"camera": {
|
||||
"make": "Apple",
|
||||
"model": "iPhone 15",
|
||||
"lens": "6mm f/1.6"
|
||||
},
|
||||
"tags": ["风景", "旅行"],
|
||||
"people": ["张三", "李四"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 批量获取照片缩略图优化
|
||||
|
||||
**问题**:
|
||||
- 当前需要为每张照片单独请求缩略图URL
|
||||
- 可能导致大量HTTP请求,影响性能
|
||||
|
||||
**建议**:
|
||||
考虑添加批量获取缩略图的接口:
|
||||
```http
|
||||
POST /api/v1/photos/thumbnails
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"hashes": ["hash1", "hash2", "hash3"],
|
||||
"size": "400x400"
|
||||
}
|
||||
```
|
||||
返回zip压缩包或者JSON格式的base64图片数据。
|
||||
|
||||
### 3. 时间线API增强
|
||||
|
||||
**问题**:
|
||||
- 当前时间线API只返回日期和数量,前端需要额外请求每天的照片详情
|
||||
- 可能导致瀑布式请求,影响加载性能
|
||||
|
||||
**建议**:
|
||||
添加一个组合API,一次性返回时间线和对应的照片信息:
|
||||
```http
|
||||
GET /api/v1/photos/timeline/full?start_date=2024-01-01&end_date=2024-12-31&photos_per_day=10
|
||||
```
|
||||
返回格式:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"2024-10-05": {
|
||||
"count": 1202,
|
||||
"photos": {
|
||||
"hash": ["hash1", "hash2"],
|
||||
"ratio": [1.5, 0.8],
|
||||
"takenAt": ["2024-10-05T10:30:00", "2024-10-05T11:30:00"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 缓存策略优化
|
||||
|
||||
**建议**:
|
||||
- 为缩略图接口添加适当的缓存头(Cache-Control, ETag等)
|
||||
- 支持条件请求(If-None-Match)减少重复传输
|
||||
|
||||
### 5. 错误处理统一
|
||||
|
||||
**观察**:
|
||||
- 当前API错误处理基本统一,但建议添加更详细的错误码
|
||||
- 可以考虑添加国际化支持的错误消息
|
||||
|
||||
**建议错误码**:
|
||||
```json
|
||||
{
|
||||
"code": 4001,
|
||||
"message": "Photo not found",
|
||||
"details": "The requested photo hash does not exist in the database"
|
||||
}
|
||||
```
|
||||
|
||||
## 前端适配情况
|
||||
|
||||
✅ **已适配**:
|
||||
- 时间线数据获取和展示
|
||||
- 照片列表获取和分页
|
||||
- 缩略图URL生成和显示
|
||||
- 错误处理和加载状态
|
||||
- 响应式布局重计算
|
||||
|
||||
⏳ **待实现**(需要后端支持):
|
||||
- 照片详情弹窗(需要metadata API)
|
||||
- 照片搜索功能
|
||||
- 照片标签和人物识别展示
|
||||
- 地理位置信息展示
|
||||
|
||||
## 总结
|
||||
|
||||
当前API设计已经可以支持基本的照片时间线功能,前端接口对接已完成。建议优先考虑实现照片元数据API,以支持更丰富的用户交互体验。
|
||||
Reference in new issue
Block a user