@anymux/ui-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ExplorerLayout-CSIJd7N4.js +105 -0
- package/dist/ExplorerLayout-CSIJd7N4.js.map +1 -0
- package/dist/FileBrowserContext-B6jixa2j.js +11 -0
- package/dist/FileBrowserContext-B6jixa2j.js.map +1 -0
- package/dist/calendar-DSlrbHoj.js +761 -0
- package/dist/calendar-DSlrbHoj.js.map +1 -0
- package/dist/calendar.d.ts +3 -0
- package/dist/calendar.js +3 -0
- package/dist/contacts-DQXTZzHc.js +539 -0
- package/dist/contacts-DQXTZzHc.js.map +1 -0
- package/dist/contacts.d.ts +3 -0
- package/dist/contacts.js +3 -0
- package/dist/file-browser-m5atC3kF.js +6755 -0
- package/dist/file-browser-m5atC3kF.js.map +1 -0
- package/dist/file-browser.d.ts +11 -0
- package/dist/file-browser.js +9 -0
- package/dist/git-B55e6LL-.js +561 -0
- package/dist/git-B55e6LL-.js.map +1 -0
- package/dist/git.d.ts +2 -0
- package/dist/git.js +3 -0
- package/dist/iconMap-V4B8P-Uh.js +206 -0
- package/dist/iconMap-V4B8P-Uh.js.map +1 -0
- package/dist/icons-CIsIOZXR.js +0 -0
- package/dist/icons.d.ts +2 -0
- package/dist/icons.js +4 -0
- package/dist/index-BNmNIWBL.d.ts +71 -0
- package/dist/index-BNmNIWBL.d.ts.map +1 -0
- package/dist/index-Bryv_GCG.d.ts +1481 -0
- package/dist/index-Bryv_GCG.d.ts.map +1 -0
- package/dist/index-CuQIjSXs.d.ts +134 -0
- package/dist/index-CuQIjSXs.d.ts.map +1 -0
- package/dist/index-DSu19mq0.d.ts +153 -0
- package/dist/index-DSu19mq0.d.ts.map +1 -0
- package/dist/index-DmsyeHFr.d.ts +149 -0
- package/dist/index-DmsyeHFr.d.ts.map +1 -0
- package/dist/index-DxnJ8FYM.d.ts +17 -0
- package/dist/index-DxnJ8FYM.d.ts.map +1 -0
- package/dist/index-DzfY1Tok.d.ts +32 -0
- package/dist/index-DzfY1Tok.d.ts.map +1 -0
- package/dist/index-Ml_SgiKa.d.ts +1847 -0
- package/dist/index-Ml_SgiKa.d.ts.map +1 -0
- package/dist/index-kHr9udZD.d.ts +1025 -0
- package/dist/index-kHr9udZD.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/layout-Ca_4r8ka.js +89 -0
- package/dist/layout-Ca_4r8ka.js.map +1 -0
- package/dist/layout.d.ts +2 -0
- package/dist/layout.js +5 -0
- package/dist/list-CxfT6hix.js +6831 -0
- package/dist/list-CxfT6hix.js.map +1 -0
- package/dist/list.d.ts +2 -0
- package/dist/list.js +5 -0
- package/dist/media-DZ292aKK.js +557 -0
- package/dist/media-DZ292aKK.js.map +1 -0
- package/dist/media.d.ts +3 -0
- package/dist/media.js +3 -0
- package/dist/tree-Dd9Z0Aso.js +3351 -0
- package/dist/tree-Dd9Z0Aso.js.map +1 -0
- package/dist/tree.d.ts +2 -0
- package/dist/tree.js +6 -0
- package/dist/types-common-CB3kRek8.d.ts +26 -0
- package/dist/types-common-CB3kRek8.d.ts.map +1 -0
- package/dist/utils-B4fdKKsy.js +3 -0
- package/package.json +109 -0
- package/src/calendar/AgendaView.tsx +37 -0
- package/src/calendar/CalendarBrowser.tsx +90 -0
- package/src/calendar/CalendarModel.ts +142 -0
- package/src/calendar/CalendarSidebar.tsx +81 -0
- package/src/calendar/DayView.tsx +76 -0
- package/src/calendar/EventCard.tsx +51 -0
- package/src/calendar/MockCalendarProvider.ts +98 -0
- package/src/calendar/MonthView.tsx +77 -0
- package/src/calendar/WeekView.tsx +129 -0
- package/src/calendar/index.ts +18 -0
- package/src/calendar/types.ts +25 -0
- package/src/contacts/ContactAvatar.tsx +35 -0
- package/src/contacts/ContactBrowser.tsx +56 -0
- package/src/contacts/ContactCard.tsx +37 -0
- package/src/contacts/ContactDetail.tsx +63 -0
- package/src/contacts/ContactGroupSidebar.tsx +40 -0
- package/src/contacts/ContactList.tsx +32 -0
- package/src/contacts/ContactListModel.ts +120 -0
- package/src/contacts/MockContactProvider.ts +77 -0
- package/src/contacts/index.ts +17 -0
- package/src/contacts/types.ts +26 -0
- package/src/demos/CalendarBrowserDemo.tsx +15 -0
- package/src/demos/ContactBrowserDemo.tsx +15 -0
- package/src/demos/MediaBrowserDemo.tsx +15 -0
- package/src/file-browser/adapters/DocumentViewerAdapter.ts +371 -0
- package/src/file-browser/adapters/FileSystemBridge.ts +168 -0
- package/src/file-browser/adapters/GitBrowserAdapter.ts +546 -0
- package/src/file-browser/adapters/README.md +504 -0
- package/src/file-browser/adapters/index.ts +27 -0
- package/src/file-browser/adapters/types.ts +70 -0
- package/src/file-browser/architecture.md +645 -0
- package/src/file-browser/components/CreateItemDialog.tsx +71 -0
- package/src/file-browser/components/DeleteConfirmDialog.tsx +58 -0
- package/src/file-browser/components/FileBrowser.tsx +473 -0
- package/src/file-browser/components/FileBrowserContent.tsx +209 -0
- package/src/file-browser/components/FileBrowserHeader.tsx +151 -0
- package/src/file-browser/components/FileBrowserToolbar.tsx +145 -0
- package/src/file-browser/components/LeftPanel/LeftPanel.tsx +103 -0
- package/src/file-browser/components/LeftPanel/LeftPanelTabs.tsx +70 -0
- package/src/file-browser/components/LeftPanel/TreeNavigationView.tsx +256 -0
- package/src/file-browser/components/PreviewPane.tsx +146 -0
- package/src/file-browser/components/RightPanel/FilePreview.tsx +219 -0
- package/src/file-browser/components/RightPanel/RightPanel.tsx +186 -0
- package/src/file-browser/components/RightPanel/RightPanelToolbar.tsx +113 -0
- package/src/file-browser/components/UploadProgress.tsx +123 -0
- package/src/file-browser/components/ViewerHost.tsx +208 -0
- package/src/file-browser/components/mobile/MobileNavigation.tsx +227 -0
- package/src/file-browser/components/navigation/NavigationButtons.tsx +171 -0
- package/src/file-browser/components/shared/ErrorBoundary.tsx +116 -0
- package/src/file-browser/components/shared/FileBrowserItem.tsx +195 -0
- package/src/file-browser/components/shared/FileIcon.tsx +169 -0
- package/src/file-browser/components/toolbar/ViewModeToggle.tsx +200 -0
- package/src/file-browser/components/views/ListView/ListView.tsx +484 -0
- package/src/file-browser/components/views/ThumbnailView/ThumbnailView.tsx +323 -0
- package/src/file-browser/components/views/TreeView/TreeNode.tsx +186 -0
- package/src/file-browser/components/views/TreeView/TreeNodeList.tsx +191 -0
- package/src/file-browser/components/views/TreeView/TreeView.tsx +200 -0
- package/src/file-browser/components/views/TreemapView/TreemapView.tsx +339 -0
- package/src/file-browser/context/FileBrowserContext.tsx +13 -0
- package/src/file-browser/examples/BasicUsage.tsx +20 -0
- package/src/file-browser/index.ts +98 -0
- package/src/file-browser/models/FileBrowserModel.ts +623 -0
- package/src/file-browser/models/LeftPanelManagerModel.ts +105 -0
- package/src/file-browser/models/NavigationManagerModel.ts +312 -0
- package/src/file-browser/models/ResponsiveLayoutManagerModel.ts +437 -0
- package/src/file-browser/models/RightPanelManagerModel.ts +190 -0
- package/src/file-browser/models/SelectionManagerModel.ts +252 -0
- package/src/file-browser/models/ToolbarManagerModel.ts +144 -0
- package/src/file-browser/models/UploadModel.ts +147 -0
- package/src/file-browser/models/ViewModeManagerModel.ts +185 -0
- package/src/file-browser/models/ViewerHostModel.ts +44 -0
- package/src/file-browser/models/ui/ListViewUIModel.ts +265 -0
- package/src/file-browser/models/ui/PreviewUIModel.ts +297 -0
- package/src/file-browser/models/ui/ThumbnailViewUIModel.ts +254 -0
- package/src/file-browser/models/ui/TreeViewUIModel.ts +128 -0
- package/src/file-browser/models/ui/TreemapViewUIModel.ts +350 -0
- package/src/file-browser/providers/FileSystemListProvider.ts +552 -0
- package/src/file-browser/providers/FileSystemProvider.ts +401 -0
- package/src/file-browser/providers/FileSystemTreeProvider.ts +231 -0
- package/src/file-browser/providers/GitProvider.ts +337 -0
- package/src/file-browser/providers/GitRepositoryProvider.ts +376 -0
- package/src/file-browser/providers/IFileBrowserProvider.ts +56 -0
- package/src/file-browser/providers/MemoryProvider.ts +303 -0
- package/src/file-browser/providers/index.ts +4 -0
- package/src/file-browser/registry/ViewerRegistry.ts +551 -0
- package/src/file-browser/registry/types.ts +144 -0
- package/src/file-browser/scripts/performanceBenchmark.ts +553 -0
- package/src/file-browser/services/ThumbnailCacheService.ts +128 -0
- package/src/file-browser/tasks.md +537 -0
- package/src/file-browser/types/FileBrowserTypes.ts +126 -0
- package/src/file-browser/types/ProviderTypes.ts +155 -0
- package/src/file-browser/types/UITypes.ts +235 -0
- package/src/file-browser/types/ViewModeTypes.ts +150 -0
- package/src/file-browser/utils/gestures.ts +327 -0
- package/src/file-browser/utils/performance.ts +563 -0
- package/src/file-browser/viewers/ImageViewer.tsx +163 -0
- package/src/file-browser/viewers/ImageViewerModel.ts +79 -0
- package/src/file-browser/viewers/TextViewer.tsx +95 -0
- package/src/file-browser/viewers/UnsupportedFileViewer.tsx +57 -0
- package/src/file-browser/viewers/index.ts +61 -0
- package/src/git/BranchList.tsx +128 -0
- package/src/git/CommitGraph.tsx +239 -0
- package/src/git/CommitList.tsx +258 -0
- package/src/git/DiffViewer.tsx +219 -0
- package/src/git/index.ts +4 -0
- package/src/icons/iconMap.ts +146 -0
- package/src/icons/index.ts +9 -0
- package/src/index.ts +13 -0
- package/src/layout/README.md +307 -0
- package/src/layout/components/ExplorerLayout/ExplorerLayout.tsx +178 -0
- package/src/layout/examples/SimpleExample.tsx +60 -0
- package/src/layout/index.ts +6 -0
- package/src/lib/utils.ts +1 -0
- package/src/list/README.md +303 -0
- package/src/list/architecture.md +807 -0
- package/src/list/components/CalculatedGridView.tsx +252 -0
- package/src/list/components/DragPreview.tsx +102 -0
- package/src/list/components/ListContextMenu.tsx +274 -0
- package/src/list/components/ListItem.tsx +761 -0
- package/src/list/components/ListItems.tsx +919 -0
- package/src/list/components/MasonryView.tsx +241 -0
- package/src/list/components/SearchFilter.tsx +44 -0
- package/src/list/components/TreemapView.tsx +709 -0
- package/src/list/components/ViewSizeControls.tsx +205 -0
- package/src/list/components/ViewTypeSelector.tsx +312 -0
- package/src/list/components/VirtualizedDetailsView.tsx +231 -0
- package/src/list/components/VirtualizedGrid.tsx +164 -0
- package/src/list/components/VirtualizedList.tsx +154 -0
- package/src/list/components/VirtualizedMasonryView.tsx +344 -0
- package/src/list/components/shared/EmptyState.tsx +103 -0
- package/src/list/components/shared/ErrorBoundary.tsx +123 -0
- package/src/list/components/shared/ErrorDisplay.tsx +100 -0
- package/src/list/components/shared/ListLoader.tsx +146 -0
- package/src/list/components/shared/LoadingIndicator.tsx +80 -0
- package/src/list/index.ts +92 -0
- package/src/list/models/ListItemsModel.ts +1301 -0
- package/src/list/models/TreemapModel.ts +204 -0
- package/src/list/providers/ListItemsProvider.ts +313 -0
- package/src/list/providers/TestListProvider.ts +604 -0
- package/src/list/tasks.md +937 -0
- package/src/list/types/ListTypes.ts +178 -0
- package/src/list/utils/BenchmarkLogger.ts +243 -0
- package/src/list/utils/DragDropManager.ts +320 -0
- package/src/list/utils/GridLayoutCalculator.ts +290 -0
- package/src/list/utils/ListAccessibility.ts +367 -0
- package/src/list/utils/ListKeyboard.ts +414 -0
- package/src/list/utils/MasonryLayoutCalculator.ts +302 -0
- package/src/list/utils/MasonryLayoutEngine.ts +401 -0
- package/src/list/utils/__tests__/MasonryLayoutEngine.test.ts +157 -0
- package/src/list/utils/__tests__/VirtualizedMasonryView.test.tsx +251 -0
- package/src/media/AlbumSidebar.tsx +48 -0
- package/src/media/MediaBrowser.tsx +92 -0
- package/src/media/MediaBrowserModel.ts +138 -0
- package/src/media/MediaGrid.tsx +50 -0
- package/src/media/MediaList.tsx +49 -0
- package/src/media/MediaPreview.tsx +63 -0
- package/src/media/MediaTimeline.tsx +38 -0
- package/src/media/MockMediaProvider.ts +70 -0
- package/src/media/index.ts +18 -0
- package/src/media/types.ts +21 -0
- package/src/styles/variables.css +60 -0
- package/src/tree/DEVELOPMENT_SUMMARY.md +170 -0
- package/src/tree/__tests__/TreeModel.test.ts +16 -0
- package/src/tree/architecture.md +530 -0
- package/src/tree/components/Tree.tsx +283 -0
- package/src/tree/components/TreeCheckbox.tsx +147 -0
- package/src/tree/components/TreeContextMenu.tsx +139 -0
- package/src/tree/components/TreeNodeList.tsx +329 -0
- package/src/tree/components/TreeTable.tsx +382 -0
- package/src/tree/index.ts +58 -0
- package/src/tree/models/TreeModel.ts +839 -0
- package/src/tree/providers/SimpleTreeProvider.ts +463 -0
- package/src/tree/providers/TestTreeProvider.ts +946 -0
- package/src/tree/providers/TreeProvider.ts +308 -0
- package/src/tree/tasks.md +2046 -0
- package/src/tree/types/TreeTypes.ts +279 -0
- package/src/tree/utils/SelectionTheme.ts +150 -0
- package/src/tree/utils/logger.ts +203 -0
- package/src/types-common.ts +21 -0
|
@@ -0,0 +1,1301 @@
|
|
|
1
|
+
import { makeAutoObservable, observable, flow } from "mobx";
|
|
2
|
+
import {
|
|
3
|
+
LIST_VIEW_TYPE,
|
|
4
|
+
} from "../providers/ListItemsProvider";
|
|
5
|
+
import type {
|
|
6
|
+
ListItemsProvider,
|
|
7
|
+
ListItemsProviderListener,
|
|
8
|
+
ListViewType,
|
|
9
|
+
} from "../providers/ListItemsProvider";
|
|
10
|
+
import type {
|
|
11
|
+
ListItemData,
|
|
12
|
+
ListLoadOptions,
|
|
13
|
+
ListLoadResult,
|
|
14
|
+
ListSelectionInfo,
|
|
15
|
+
ListDragDropInfo
|
|
16
|
+
} from "../types/ListTypes";
|
|
17
|
+
import { benchmark } from "../utils/BenchmarkLogger";
|
|
18
|
+
import { GridLayoutCalculator, createGridCalculator, type GridItemLayout } from "../utils/GridLayoutCalculator";
|
|
19
|
+
import { MasonryLayoutEngine, createVerticalMasonry, createHorizontalMasonry, type MasonryItem, type MasonryItemPosition } from "../utils/MasonryLayoutEngine";
|
|
20
|
+
|
|
21
|
+
// AICODE-NOTE: List could be in different states: thumbnail, grid, list, details and shuld be possible to define custom states..
|
|
22
|
+
// AICODE-NOTE: It could be used in file explorer like windows explorer or finder but also in other places
|
|
23
|
+
// AICODE-NOTE: Should use virtualized list like react-window
|
|
24
|
+
// AICODE-NOTE: for provider/model design see ../TreeComponent/models/TreeModel.ts
|
|
25
|
+
|
|
26
|
+
// AICODE-NOTE: Skeleton UI or shimmer for items while loading; graceful error display.
|
|
27
|
+
// AICODE-NOTE: Expose imperative methods (scroll to item, focus item, programmatically select, refresh, etc.).
|
|
28
|
+
// AICODE-NOTE: Support single, multiple, and range selection with shift/control modifiers; expose selected item(s) via API.
|
|
29
|
+
// AICODE-NOTE: Think about Item interface, it should be flexible and support different types of items.
|
|
30
|
+
|
|
31
|
+
export class ListItemsModel implements ListItemsProviderListener {
|
|
32
|
+
currentViewType: ListViewType = LIST_VIEW_TYPE;
|
|
33
|
+
|
|
34
|
+
// AICODE-NOTE: Core data using observable collections for reactive updates
|
|
35
|
+
_allItems: ListItemData[] = [];
|
|
36
|
+
itemMap = observable.map<string, ListItemData>();
|
|
37
|
+
totalItemCount: number = 0;
|
|
38
|
+
|
|
39
|
+
// Search/filter state
|
|
40
|
+
searchQuery: string = '';
|
|
41
|
+
|
|
42
|
+
// AICODE-NOTE: Selection state using observable.map for O(1) lookups
|
|
43
|
+
selectedItems = observable.map<string, ListItemData>();
|
|
44
|
+
previousSelection: ListItemData[] = [];
|
|
45
|
+
focusedItem: string | null = null;
|
|
46
|
+
|
|
47
|
+
// Range selection anchor - tracks the starting point for shift+click range selection
|
|
48
|
+
selectionAnchor: string | null = null;
|
|
49
|
+
|
|
50
|
+
// AICODE-NOTE: Loading state for different ranges and operations
|
|
51
|
+
isLoading: boolean = false;
|
|
52
|
+
loadingRanges = observable.map<string, boolean>();
|
|
53
|
+
errors = observable.map<string, Error>();
|
|
54
|
+
|
|
55
|
+
// AICODE-NOTE: Virtualization state for handling huge datasets
|
|
56
|
+
viewportRange: { start: number; end: number } = { start: 0, end: 0 };
|
|
57
|
+
scrollPosition: number = 0;
|
|
58
|
+
containerSize: { width: number; height: number } = { width: 0, height: 0 };
|
|
59
|
+
|
|
60
|
+
// AICODE-NOTE: Drag and drop state
|
|
61
|
+
isDragging: boolean = false;
|
|
62
|
+
draggedItems: ListItemData[] = [];
|
|
63
|
+
dragOverItem: string | null = null;
|
|
64
|
+
dragOverPosition: 'before' | 'after' | 'inside' | null = null;
|
|
65
|
+
|
|
66
|
+
// AICODE-NOTE: View size management state
|
|
67
|
+
itemSize: 'small' | 'medium' | 'large' | 'extra-large' = 'medium';
|
|
68
|
+
customItemWidth: number = 200;
|
|
69
|
+
customItemHeight: number = 220;
|
|
70
|
+
itemsPerRow: number | 'auto' = 'auto';
|
|
71
|
+
|
|
72
|
+
// AICODE-NOTE: Layout calculators for precise positioning
|
|
73
|
+
private gridCalculator: GridLayoutCalculator | null = null;
|
|
74
|
+
private verticalMasonryEngine: MasonryLayoutEngine | null = null;
|
|
75
|
+
private horizontalMasonryEngine: MasonryLayoutEngine | null = null;
|
|
76
|
+
|
|
77
|
+
// AICODE-NOTE: Debug visualization toggle
|
|
78
|
+
debugVisualization: boolean = false;
|
|
79
|
+
|
|
80
|
+
// Compact mode: details view shows only Icon + Name
|
|
81
|
+
compactMode: boolean = false;
|
|
82
|
+
|
|
83
|
+
// Column visibility for details view (only relevant when compactMode is false)
|
|
84
|
+
columnVisibility: { type: boolean; modified: boolean; size: boolean } = {
|
|
85
|
+
type: true,
|
|
86
|
+
modified: true,
|
|
87
|
+
size: true
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Checkbox mode for multi-select
|
|
91
|
+
showCheckboxes: boolean = false;
|
|
92
|
+
|
|
93
|
+
// Scroll-to-item: views watch this and scroll their container when set
|
|
94
|
+
scrollToItemId: string | null = null;
|
|
95
|
+
|
|
96
|
+
// Pagination state
|
|
97
|
+
pageSize: number = 50;
|
|
98
|
+
currentPage: number = 0;
|
|
99
|
+
|
|
100
|
+
constructor(public provider: ListItemsProvider) {
|
|
101
|
+
makeAutoObservable(this, {});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// =====================
|
|
105
|
+
// Computed filtered items
|
|
106
|
+
// =====================
|
|
107
|
+
|
|
108
|
+
get items(): ListItemData[] {
|
|
109
|
+
if (!this.searchQuery) return this._allItems;
|
|
110
|
+
const q = this.searchQuery.toLowerCase();
|
|
111
|
+
return this._allItems.filter(item => item.name.toLowerCase().includes(q));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get hasSearchQuery(): boolean {
|
|
115
|
+
return this.searchQuery.length > 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get allItemsCount(): number {
|
|
119
|
+
return this._allItems.length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
setSearchQuery(query: string): void {
|
|
123
|
+
this.searchQuery = query;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clearSearch(): void {
|
|
127
|
+
this.searchQuery = '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
clearItems(): void {
|
|
131
|
+
this._allItems = [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// =====================
|
|
135
|
+
// ListItemsProviderListener Implementation
|
|
136
|
+
// =====================
|
|
137
|
+
|
|
138
|
+
onItemsCountChanged(count: number): void {
|
|
139
|
+
this.totalItemCount = count;
|
|
140
|
+
// AICODE-NOTE: Notify UI components that the total count has changed
|
|
141
|
+
// This is useful for virtualization and pagination
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onItemChanged(itemIndex: number): void {
|
|
145
|
+
// AICODE-NOTE: Handle individual item changes for reactive updates
|
|
146
|
+
// This allows providers to notify of granular changes without full reload
|
|
147
|
+
if (itemIndex >= 0 && itemIndex < this._allItems.length) {
|
|
148
|
+
// Trigger re-render for specific item by updating the map
|
|
149
|
+
const item = this._allItems[itemIndex];
|
|
150
|
+
if (item) {
|
|
151
|
+
this.itemMap.set(item.id, { ...item });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// =====================
|
|
157
|
+
// Computed Properties
|
|
158
|
+
// =====================
|
|
159
|
+
|
|
160
|
+
get selectedItemsArray(): ListItemData[] {
|
|
161
|
+
return Array.from(this.selectedItems.values());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get hasSelection(): boolean {
|
|
165
|
+
return this.selectedItems.size > 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get isItemSelected() {
|
|
169
|
+
return (itemId: string) => this.selectedItems.has(itemId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get itemHeight(): number {
|
|
173
|
+
return this.provider.getItemHeight(this.currentViewType);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get isLoaded(): boolean {
|
|
177
|
+
return !this.isLoading && this._allItems.length > 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get hasErrors(): boolean {
|
|
181
|
+
return this.errors.size > 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// AICODE-NOTE: Performance optimized computed properties
|
|
185
|
+
get selectedItemIds(): Set<string> {
|
|
186
|
+
return new Set(this.selectedItems.keys());
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get visibleItemsCount(): number {
|
|
190
|
+
const { start, end } = this.viewportRange;
|
|
191
|
+
return Math.max(0, end - start + 1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get loadedItemsCount(): number {
|
|
195
|
+
return this._allItems.filter(item => item !== null && item !== undefined).length;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get loadingProgress(): number {
|
|
199
|
+
if (this.totalItemCount === 0) return 0;
|
|
200
|
+
return Math.min(100, (this.loadedItemsCount / this.totalItemCount) * 100);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
get isFullyLoaded(): boolean {
|
|
204
|
+
return this.loadedItemsCount >= this.totalItemCount;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get totalPages(): number {
|
|
208
|
+
if (this.pageSize <= 0 || this.totalItemCount <= 0) return 0;
|
|
209
|
+
return Math.ceil(this.totalItemCount / this.pageSize);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
get hasNextPage(): boolean {
|
|
213
|
+
return this.currentPage < this.totalPages - 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
get hasPreviousPage(): boolean {
|
|
217
|
+
return this.currentPage > 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// =====================
|
|
221
|
+
// Pagination Actions
|
|
222
|
+
// =====================
|
|
223
|
+
|
|
224
|
+
goToPage = flow(function* (this: ListItemsModel, page: number) {
|
|
225
|
+
if (page < 0 || page >= this.totalPages) return;
|
|
226
|
+
this.currentPage = page;
|
|
227
|
+
yield this.loadItems({ limit: this.pageSize, offset: page * this.pageSize });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
nextPage = flow(function* (this: ListItemsModel) {
|
|
231
|
+
if (this.hasNextPage) {
|
|
232
|
+
yield this.goToPage(this.currentPage + 1);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
prevPage = flow(function* (this: ListItemsModel) {
|
|
237
|
+
if (this.hasPreviousPage) {
|
|
238
|
+
yield this.goToPage(this.currentPage - 1);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
setPageSize(size: number): void {
|
|
243
|
+
if (size > 0) {
|
|
244
|
+
this.pageSize = size;
|
|
245
|
+
this.currentPage = 0;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// =====================
|
|
250
|
+
// Async Actions (using flow for MobX best practices)
|
|
251
|
+
// =====================
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Load items from the provider
|
|
255
|
+
* Uses MobX flow for proper async action handling
|
|
256
|
+
*/
|
|
257
|
+
loadItems = flow(function* (this: ListItemsModel, options?: ListLoadOptions) {
|
|
258
|
+
benchmark.start('loadItems', {
|
|
259
|
+
itemCount: this.items.length,
|
|
260
|
+
viewType: this.currentViewType.id,
|
|
261
|
+
options
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
this.isLoading = true;
|
|
265
|
+
this.errors.clear();
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
benchmark.start('provider.loadItems');
|
|
269
|
+
const result: ListLoadResult = yield this.provider.loadItems(options);
|
|
270
|
+
benchmark.end('provider.loadItems', { itemCount: result.items.length });
|
|
271
|
+
|
|
272
|
+
benchmark.start('updateItemsAndMap');
|
|
273
|
+
// Update items and item map
|
|
274
|
+
this._allItems = result.items;
|
|
275
|
+
this.totalItemCount = result.totalCount ?? result.items.length;
|
|
276
|
+
|
|
277
|
+
// Update item map for fast lookups
|
|
278
|
+
this.itemMap.clear();
|
|
279
|
+
result.items.forEach((item: ListItemData) => this.itemMap.set(item.id, item));
|
|
280
|
+
benchmark.end('updateItemsAndMap', { itemCount: result.items.length });
|
|
281
|
+
|
|
282
|
+
} catch (error) {
|
|
283
|
+
const errorKey = 'loadItems';
|
|
284
|
+
this.errors.set(errorKey, error instanceof Error ? error : new Error(String(error)));
|
|
285
|
+
} finally {
|
|
286
|
+
this.isLoading = false;
|
|
287
|
+
benchmark.end('loadItems', {
|
|
288
|
+
finalItemCount: this.items.length,
|
|
289
|
+
hasErrors: this.errors.size > 0
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Load a specific range of items for virtualization
|
|
296
|
+
*/
|
|
297
|
+
loadItemRange = flow(function* (this: ListItemsModel, start: number, end: number) {
|
|
298
|
+
const rangeKey = `${start}-${end}`;
|
|
299
|
+
this.loadingRanges.set(rangeKey, true);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const result: ListLoadResult = yield this.provider.loadItemRange?.(start, end) ?? { items: [] };
|
|
303
|
+
|
|
304
|
+
// Merge items into existing array at correct positions
|
|
305
|
+
result.items.forEach((item, index) => {
|
|
306
|
+
const absoluteIndex = start + index;
|
|
307
|
+
this._allItems[absoluteIndex] = item;
|
|
308
|
+
this.itemMap.set(item.id, item);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
} catch (error) {
|
|
312
|
+
this.errors.set(rangeKey, error instanceof Error ? error : new Error(String(error)));
|
|
313
|
+
} finally {
|
|
314
|
+
this.loadingRanges.delete(rangeKey);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Refresh items from provider
|
|
320
|
+
*/
|
|
321
|
+
refresh = flow(function* (this: ListItemsModel) {
|
|
322
|
+
if (this.provider.refresh) {
|
|
323
|
+
this.isLoading = true;
|
|
324
|
+
this.errors.clear();
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const result: ListLoadResult = yield this.provider.refresh();
|
|
328
|
+
this._allItems = result.items;
|
|
329
|
+
this.totalItemCount = result.totalCount ?? result.items.length;
|
|
330
|
+
|
|
331
|
+
// Update item map
|
|
332
|
+
this.itemMap.clear();
|
|
333
|
+
result.items.forEach((item: ListItemData) => this.itemMap.set(item.id, item));
|
|
334
|
+
|
|
335
|
+
} catch (error) {
|
|
336
|
+
this.errors.set('refresh', error instanceof Error ? error : new Error(String(error)));
|
|
337
|
+
} finally {
|
|
338
|
+
this.isLoading = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// =====================
|
|
344
|
+
// Selection Actions
|
|
345
|
+
// =====================
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Select a single item with proper single/multi-selection logic
|
|
349
|
+
*/
|
|
350
|
+
selectItem(item: ListItemData, modifiers?: { ctrl?: boolean; shift?: boolean }): void {
|
|
351
|
+
// Store previous selection for callback
|
|
352
|
+
this.previousSelection = [...this.selectedItemsArray];
|
|
353
|
+
|
|
354
|
+
const wasSelected = this.selectedItems.has(item.id);
|
|
355
|
+
const isMultiSelectMode = this.provider.isMultiSelectEnabled && modifiers?.ctrl;
|
|
356
|
+
const isRangeSelectMode = this.provider.isMultiSelectEnabled && modifiers?.shift;
|
|
357
|
+
|
|
358
|
+
if (isRangeSelectMode && this.selectionAnchor) {
|
|
359
|
+
// Shift+click: select range from anchor to clicked item
|
|
360
|
+
this.selectRange(this.selectionAnchor, item.id);
|
|
361
|
+
} else if (isMultiSelectMode) {
|
|
362
|
+
// Ctrl+click: toggle individual item
|
|
363
|
+
if (wasSelected) {
|
|
364
|
+
this.selectedItems.delete(item.id);
|
|
365
|
+
} else {
|
|
366
|
+
this.selectedItems.set(item.id, item);
|
|
367
|
+
}
|
|
368
|
+
// Update anchor to current item for future range selections
|
|
369
|
+
this.selectionAnchor = item.id;
|
|
370
|
+
} else {
|
|
371
|
+
// Plain click: single selection
|
|
372
|
+
this.selectedItems.clear();
|
|
373
|
+
this.selectedItems.set(item.id, item);
|
|
374
|
+
// Set anchor for future shift+click range selections
|
|
375
|
+
this.selectionAnchor = item.id;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Always update focused item when clicking
|
|
379
|
+
this.focusedItem = item.id;
|
|
380
|
+
|
|
381
|
+
// Notify provider of selection change
|
|
382
|
+
if (this.provider.onSelectionChange) {
|
|
383
|
+
this.provider.onSelectionChange({
|
|
384
|
+
selectedItems: this.selectedItemsArray,
|
|
385
|
+
previousSelection: this.previousSelection,
|
|
386
|
+
selectionType: isRangeSelectMode ? 'range' : (isMultiSelectMode ? 'multi' : 'single'),
|
|
387
|
+
trigger: 'click'
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Select a contiguous range of items between two item IDs
|
|
394
|
+
*/
|
|
395
|
+
selectRange(fromId: string, toId: string): void {
|
|
396
|
+
const fromIndex = this.items.findIndex(item => item.id === fromId);
|
|
397
|
+
const toIndex = this.items.findIndex(item => item.id === toId);
|
|
398
|
+
|
|
399
|
+
if (fromIndex === -1 || toIndex === -1) return;
|
|
400
|
+
|
|
401
|
+
const start = Math.min(fromIndex, toIndex);
|
|
402
|
+
const end = Math.max(fromIndex, toIndex);
|
|
403
|
+
|
|
404
|
+
this.selectedItems.clear();
|
|
405
|
+
for (let i = start; i <= end; i++) {
|
|
406
|
+
const item = this.items[i];
|
|
407
|
+
if (item) {
|
|
408
|
+
this.selectedItems.set(item.id, item);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Clear all selection
|
|
415
|
+
*/
|
|
416
|
+
clearSelection(): void {
|
|
417
|
+
const hadSelection = this.selectedItems.size > 0;
|
|
418
|
+
if (!hadSelection) return;
|
|
419
|
+
|
|
420
|
+
// Store previous selection for callback
|
|
421
|
+
this.previousSelection = [...this.selectedItemsArray];
|
|
422
|
+
this.selectedItems.clear();
|
|
423
|
+
|
|
424
|
+
if (this.provider.onSelectionChange) {
|
|
425
|
+
this.provider.onSelectionChange({
|
|
426
|
+
selectedItems: [],
|
|
427
|
+
previousSelection: this.previousSelection,
|
|
428
|
+
selectionType: this.provider.isMultiSelectEnabled ? 'multi' : 'single',
|
|
429
|
+
trigger: 'api'
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Select all items (if multi-select enabled)
|
|
436
|
+
*/
|
|
437
|
+
selectAll(): void {
|
|
438
|
+
if (!this.provider.isMultiSelectEnabled) return;
|
|
439
|
+
|
|
440
|
+
this.selectedItems.clear();
|
|
441
|
+
this.items.forEach(item => {
|
|
442
|
+
this.selectedItems.set(item.id, item);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
if (this.provider.onSelectionChange) {
|
|
446
|
+
this.provider.onSelectionChange({
|
|
447
|
+
selectedItems: this.selectedItemsArray,
|
|
448
|
+
previousSelection: [],
|
|
449
|
+
selectionType: 'multi',
|
|
450
|
+
trigger: 'api'
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// =====================
|
|
456
|
+
// View Type Management
|
|
457
|
+
// =====================
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Set the current view type
|
|
461
|
+
*/
|
|
462
|
+
setViewType(viewType: ListViewType): void {
|
|
463
|
+
console.log('ListItemsModel.setViewType called:', {
|
|
464
|
+
from: this.currentViewType.id,
|
|
465
|
+
to: viewType.id,
|
|
466
|
+
same: this.currentViewType.id === viewType.id
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
if (this.currentViewType.id !== viewType.id) {
|
|
470
|
+
this.currentViewType = viewType;
|
|
471
|
+
console.log('View type changed to:', this.currentViewType.id);
|
|
472
|
+
|
|
473
|
+
// Notify provider
|
|
474
|
+
if (this.provider.onViewTypeChange) {
|
|
475
|
+
this.provider.onViewTypeChange(viewType);
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
console.log('View type not changed - same as current');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// =====================
|
|
483
|
+
// Size Management
|
|
484
|
+
// =====================
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Set item size for grid and masonry views
|
|
488
|
+
*/
|
|
489
|
+
setItemSize(size: 'small' | 'medium' | 'large' | 'extra-large'): void {
|
|
490
|
+
this.itemSize = size;
|
|
491
|
+
|
|
492
|
+
// Update custom dimensions based on size
|
|
493
|
+
const sizeMap = {
|
|
494
|
+
'small': { width: 160, height: 120 },
|
|
495
|
+
'medium': { width: 240, height: 180 },
|
|
496
|
+
'large': { width: 320, height: 240 },
|
|
497
|
+
'extra-large': { width: 400, height: 300 }
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const dimensions = sizeMap[size];
|
|
501
|
+
this.customItemWidth = dimensions.width;
|
|
502
|
+
this.customItemHeight = dimensions.height;
|
|
503
|
+
|
|
504
|
+
// Update calculators with new dimensions
|
|
505
|
+
this.updateLayoutCalculators();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Set custom item width and update calculators
|
|
510
|
+
*/
|
|
511
|
+
setCustomItemWidth(width: number): void {
|
|
512
|
+
this.customItemWidth = Math.max(120, Math.min(600, width));
|
|
513
|
+
this.updateLayoutCalculators();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Set custom item height and update calculators
|
|
518
|
+
*/
|
|
519
|
+
setCustomItemHeight(height: number): void {
|
|
520
|
+
this.customItemHeight = Math.max(80, Math.min(500, height));
|
|
521
|
+
this.updateLayoutCalculators();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Get current item dimensions
|
|
526
|
+
*/
|
|
527
|
+
get itemDimensions(): { width: number; height: number } {
|
|
528
|
+
return {
|
|
529
|
+
width: this.customItemWidth,
|
|
530
|
+
height: this.customItemHeight
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Set items per row for grid and masonry views
|
|
536
|
+
*/
|
|
537
|
+
setItemsPerRow(itemsPerRow: number | 'auto'): void {
|
|
538
|
+
this.itemsPerRow = itemsPerRow;
|
|
539
|
+
this.updateLayoutCalculators();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Set debug visualization mode
|
|
544
|
+
*/
|
|
545
|
+
setDebugVisualization(enabled: boolean): void {
|
|
546
|
+
this.debugVisualization = enabled;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
setCompactMode(compact: boolean): void {
|
|
550
|
+
this.compactMode = compact;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
toggleCheckboxes(): void {
|
|
554
|
+
this.showCheckboxes = !this.showCheckboxes;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
setShowCheckboxes(show: boolean): void {
|
|
558
|
+
this.showCheckboxes = show;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
setColumnVisibility(column: 'type' | 'modified' | 'size', visible: boolean): void {
|
|
562
|
+
this.columnVisibility = { ...this.columnVisibility, [column]: visible };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
get detailsGridTemplateColumns(): string {
|
|
566
|
+
const cols: string[] = [];
|
|
567
|
+
if (this.showCheckboxes) cols.push('20px');
|
|
568
|
+
cols.push('24px', 'minmax(0, 1fr)');
|
|
569
|
+
if (!this.compactMode) {
|
|
570
|
+
if (this.columnVisibility.type) cols.push('100px');
|
|
571
|
+
if (this.columnVisibility.modified) cols.push('140px');
|
|
572
|
+
if (this.columnVisibility.size) cols.push('90px');
|
|
573
|
+
}
|
|
574
|
+
return cols.join(' ');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Generate random aspect ratio for Pinterest-like variety
|
|
579
|
+
*/
|
|
580
|
+
/**
|
|
581
|
+
* Deterministic aspect ratio based on item id (stable across re-renders).
|
|
582
|
+
* Uses a simple hash to pick from predefined ratios.
|
|
583
|
+
*/
|
|
584
|
+
private generateDeterministicAspectRatio(id: string): number {
|
|
585
|
+
const ratios = [0.6, 0.7, 0.8, 1.0, 1.2, 1.4, 1.6];
|
|
586
|
+
let hash = 0;
|
|
587
|
+
for (let i = 0; i < id.length; i++) {
|
|
588
|
+
hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
|
|
589
|
+
}
|
|
590
|
+
const index = Math.abs(hash) % ratios.length;
|
|
591
|
+
return ratios[index] ?? 1.0;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Get effective items per row based on view and container width
|
|
596
|
+
*/
|
|
597
|
+
getEffectiveItemsPerRow(containerWidth?: number): number {
|
|
598
|
+
if (typeof this.itemsPerRow === 'number') {
|
|
599
|
+
return this.itemsPerRow;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Auto calculation based on container width and item width
|
|
603
|
+
if (containerWidth) {
|
|
604
|
+
const itemWidth = this.customItemWidth;
|
|
605
|
+
const gap = 12; // Default gap
|
|
606
|
+
const availableWidth = containerWidth - (gap * 2);
|
|
607
|
+
const itemsWithGaps = Math.floor((availableWidth + gap) / (itemWidth + gap));
|
|
608
|
+
return Math.max(1, Math.min(8, itemsWithGaps));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return 4; // Default fallback
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// =====================
|
|
615
|
+
// Focus Management
|
|
616
|
+
// =====================
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Set focused item
|
|
620
|
+
*/
|
|
621
|
+
setFocusedItem(itemId: string | null): void {
|
|
622
|
+
this.focusedItem = itemId;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Focus next item
|
|
627
|
+
*/
|
|
628
|
+
focusNext(): boolean {
|
|
629
|
+
if (this.items.length === 0) return false;
|
|
630
|
+
|
|
631
|
+
const currentIndex = this.focusedItem
|
|
632
|
+
? this.items.findIndex(item => item.id === this.focusedItem)
|
|
633
|
+
: -1;
|
|
634
|
+
|
|
635
|
+
const nextIndex = currentIndex < this.items.length - 1 ? currentIndex + 1 : 0;
|
|
636
|
+
const nextItem = this.items[nextIndex];
|
|
637
|
+
if (nextItem) {
|
|
638
|
+
this.focusedItem = nextItem.id;
|
|
639
|
+
}
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Focus previous item
|
|
645
|
+
*/
|
|
646
|
+
focusPrevious(): boolean {
|
|
647
|
+
if (this.items.length === 0) return false;
|
|
648
|
+
|
|
649
|
+
const currentIndex = this.focusedItem
|
|
650
|
+
? this.items.findIndex(item => item.id === this.focusedItem)
|
|
651
|
+
: 0;
|
|
652
|
+
|
|
653
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : this.items.length - 1;
|
|
654
|
+
const prevItem = this.items[prevIndex];
|
|
655
|
+
if (prevItem) {
|
|
656
|
+
this.focusedItem = prevItem.id;
|
|
657
|
+
}
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Focus the first item in the list
|
|
663
|
+
*/
|
|
664
|
+
focusFirst(): boolean {
|
|
665
|
+
if (this.items.length === 0) return false;
|
|
666
|
+
const firstItem = this.items[0];
|
|
667
|
+
if (firstItem) {
|
|
668
|
+
this.focusedItem = firstItem.id;
|
|
669
|
+
this.ensureItemVisible(firstItem.id);
|
|
670
|
+
}
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Focus the last item in the list
|
|
676
|
+
*/
|
|
677
|
+
focusLast(): boolean {
|
|
678
|
+
if (this.items.length === 0) return false;
|
|
679
|
+
const lastItem = this.items[this.items.length - 1];
|
|
680
|
+
if (lastItem) {
|
|
681
|
+
this.focusedItem = lastItem.id;
|
|
682
|
+
this.ensureItemVisible(lastItem.id);
|
|
683
|
+
}
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// =====================
|
|
688
|
+
// Keyboard Navigation
|
|
689
|
+
// =====================
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Handle keyboard events for list navigation.
|
|
693
|
+
* Can be used directly via onKeyDown on a container element,
|
|
694
|
+
* or the useListKeyboard hook can be used for automatic attachment.
|
|
695
|
+
*
|
|
696
|
+
* - ArrowUp/ArrowDown: navigate between items
|
|
697
|
+
* - Home/End: jump to first/last item
|
|
698
|
+
* - Enter: open focused item (navigate into folder or open file)
|
|
699
|
+
* - Space: toggle selection of focused item
|
|
700
|
+
* - Escape: clear selection, or navigate up one directory level if nothing selected
|
|
701
|
+
* - Ctrl+A: select all items
|
|
702
|
+
*/
|
|
703
|
+
handleKeyDown(event: KeyboardEvent): void {
|
|
704
|
+
// Skip when an input/textarea is focused (e.g. inline rename)
|
|
705
|
+
const tag = (event.target as HTMLElement)?.tagName;
|
|
706
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
707
|
+
|
|
708
|
+
if (this.items.length === 0) return;
|
|
709
|
+
|
|
710
|
+
const { key, ctrlKey, metaKey, shiftKey } = event;
|
|
711
|
+
const isModifier = ctrlKey || metaKey;
|
|
712
|
+
|
|
713
|
+
switch (key) {
|
|
714
|
+
case 'ArrowUp': {
|
|
715
|
+
event.preventDefault();
|
|
716
|
+
const idx = this.getFocusedIndex();
|
|
717
|
+
if (idx > 0) {
|
|
718
|
+
this.navigateToIndex(idx - 1, shiftKey);
|
|
719
|
+
}
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
case 'ArrowDown': {
|
|
723
|
+
event.preventDefault();
|
|
724
|
+
const idx = this.getFocusedIndex();
|
|
725
|
+
if (idx < this.items.length - 1) {
|
|
726
|
+
this.navigateToIndex(idx + 1, shiftKey);
|
|
727
|
+
}
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
case 'Home': {
|
|
731
|
+
event.preventDefault();
|
|
732
|
+
this.navigateToIndex(0, shiftKey);
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
case 'End': {
|
|
736
|
+
event.preventDefault();
|
|
737
|
+
this.navigateToIndex(this.items.length - 1, shiftKey);
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
case 'Enter': {
|
|
741
|
+
event.preventDefault();
|
|
742
|
+
const item = this.focusedItem ? this.getItem(this.focusedItem) : this.items[0];
|
|
743
|
+
if (item && this.provider.onItemDoubleClick) {
|
|
744
|
+
this.provider.onItemDoubleClick(item);
|
|
745
|
+
}
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
case ' ': {
|
|
749
|
+
event.preventDefault();
|
|
750
|
+
const item = this.focusedItem ? this.getItem(this.focusedItem) : this.items[0];
|
|
751
|
+
if (item) {
|
|
752
|
+
this.selectItem(item, { ctrl: true, shift: shiftKey });
|
|
753
|
+
}
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
case 'Escape': {
|
|
757
|
+
event.preventDefault();
|
|
758
|
+
if (this.hasSelection) {
|
|
759
|
+
this.clearSelection();
|
|
760
|
+
} else if (this.provider.onNavigateUp) {
|
|
761
|
+
this.provider.onNavigateUp();
|
|
762
|
+
}
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
case 'a':
|
|
766
|
+
case 'A': {
|
|
767
|
+
if (isModifier) {
|
|
768
|
+
event.preventDefault();
|
|
769
|
+
this.selectAll();
|
|
770
|
+
}
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Get the index of the currently focused item, defaulting to 0
|
|
778
|
+
*/
|
|
779
|
+
private getFocusedIndex(): number {
|
|
780
|
+
if (!this.focusedItem) return 0;
|
|
781
|
+
const idx = this.items.findIndex(item => item.id === this.focusedItem);
|
|
782
|
+
return idx === -1 ? 0 : idx;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Navigate to a specific item index, updating focus, selection, and scroll
|
|
787
|
+
*/
|
|
788
|
+
private navigateToIndex(index: number, extendSelection: boolean = false): void {
|
|
789
|
+
const item = this.items[index];
|
|
790
|
+
if (!item) return;
|
|
791
|
+
|
|
792
|
+
this.focusedItem = item.id;
|
|
793
|
+
|
|
794
|
+
if (extendSelection && this.provider.isMultiSelectEnabled) {
|
|
795
|
+
if (!this.selectionAnchor) {
|
|
796
|
+
this.selectionAnchor = this.focusedItem || item.id;
|
|
797
|
+
}
|
|
798
|
+
this.selectRange(this.selectionAnchor, item.id);
|
|
799
|
+
} else {
|
|
800
|
+
this.selectItem(item);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
this.ensureItemVisible(item.id);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// =====================
|
|
807
|
+
// Drag and Drop Management
|
|
808
|
+
// =====================
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Check if an item can be dragged
|
|
812
|
+
*/
|
|
813
|
+
canDragItem(item: ListItemData): boolean {
|
|
814
|
+
if (!this.provider.isDragDropEnabled) return false;
|
|
815
|
+
return this.provider.canDragItem?.(item) ?? true;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Check if items can be dropped on a target
|
|
820
|
+
*/
|
|
821
|
+
canDropItems(draggedItems: ListItemData[], targetItem: ListItemData | null, position: 'before' | 'after' | 'inside'): boolean {
|
|
822
|
+
if (!this.provider.isDragDropEnabled) return false;
|
|
823
|
+
return this.provider.canDropItems?.(draggedItems, targetItem, position) ?? true;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Start drag operation
|
|
828
|
+
*/
|
|
829
|
+
startDrag(items: ListItemData[], event: DragEvent): void {
|
|
830
|
+
if (!this.provider.isDragDropEnabled) return;
|
|
831
|
+
|
|
832
|
+
this.isDragging = true;
|
|
833
|
+
this.draggedItems = items;
|
|
834
|
+
|
|
835
|
+
// AICODE-NOTE: Set drag data for HTML5 drag and drop
|
|
836
|
+
const itemIds = items.map(item => item.id);
|
|
837
|
+
event.dataTransfer?.setData('application/json', JSON.stringify({
|
|
838
|
+
type: 'list-items',
|
|
839
|
+
itemIds
|
|
840
|
+
}));
|
|
841
|
+
|
|
842
|
+
// AICODE-NOTE: Set drag effect
|
|
843
|
+
if (event.dataTransfer) {
|
|
844
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Notify provider
|
|
848
|
+
if (this.provider.onDragStart) {
|
|
849
|
+
this.provider.onDragStart(items, event);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Handle drag over item
|
|
855
|
+
*/
|
|
856
|
+
setDragOver(itemId: string | null, position: 'before' | 'after' | 'inside' | null): void {
|
|
857
|
+
this.dragOverItem = itemId;
|
|
858
|
+
this.dragOverPosition = position;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Handle drop operation
|
|
863
|
+
*/
|
|
864
|
+
handleDrop(targetItem: ListItemData | null, position: 'before' | 'after' | 'inside', event: DragEvent): boolean {
|
|
865
|
+
if (!this.isDragging || this.draggedItems.length === 0) return false;
|
|
866
|
+
|
|
867
|
+
// AICODE-NOTE: Check if drop is allowed
|
|
868
|
+
if (!this.canDropItems(this.draggedItems, targetItem, position)) {
|
|
869
|
+
this.endDrag(false);
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// AICODE-NOTE: Create drag drop info
|
|
874
|
+
const dragDropInfo: ListDragDropInfo = {
|
|
875
|
+
draggedItems: this.draggedItems,
|
|
876
|
+
targetItem,
|
|
877
|
+
position,
|
|
878
|
+
event
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// Notify provider
|
|
882
|
+
if (this.provider.onDrop) {
|
|
883
|
+
this.provider.onDrop(dragDropInfo);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
this.endDrag(true);
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* End drag operation
|
|
892
|
+
*/
|
|
893
|
+
endDrag(success: boolean): void {
|
|
894
|
+
const draggedItems = [...this.draggedItems];
|
|
895
|
+
|
|
896
|
+
this.isDragging = false;
|
|
897
|
+
this.draggedItems = [];
|
|
898
|
+
this.dragOverItem = null;
|
|
899
|
+
this.dragOverPosition = null;
|
|
900
|
+
|
|
901
|
+
// Notify provider
|
|
902
|
+
if (this.provider.onDragEnd) {
|
|
903
|
+
this.provider.onDragEnd(draggedItems, success);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// =====================
|
|
908
|
+
// Virtualization Support
|
|
909
|
+
// =====================
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Update viewport range for virtualization and trigger dynamic loading
|
|
913
|
+
*/
|
|
914
|
+
updateViewportRange(start: number, end: number): void {
|
|
915
|
+
this.viewportRange = { start, end };
|
|
916
|
+
|
|
917
|
+
// AICODE-NOTE: Trigger dynamic loading for visible range
|
|
918
|
+
this.loadVisibleItems(start, end);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Load items that are visible in the current viewport
|
|
923
|
+
*/
|
|
924
|
+
private loadVisibleItems = flow(function* (this: ListItemsModel, start: number, end: number) {
|
|
925
|
+
// AICODE-NOTE: Check if provider supports range loading
|
|
926
|
+
if (!this.provider.loadItemRange) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// AICODE-NOTE: Expand range to include overscan for smoother scrolling
|
|
931
|
+
const overscan = 10;
|
|
932
|
+
const expandedStart = Math.max(0, start - overscan);
|
|
933
|
+
const expandedEnd = Math.min(this.totalItemCount, end + overscan);
|
|
934
|
+
|
|
935
|
+
// AICODE-NOTE: Check which items in the range are missing
|
|
936
|
+
const missingRanges: Array<{ start: number; end: number }> = [];
|
|
937
|
+
let rangeStart = -1;
|
|
938
|
+
|
|
939
|
+
for (let i = expandedStart; i <= expandedEnd; i++) {
|
|
940
|
+
const hasItem = i < this._allItems.length && this._allItems[i] !== undefined;
|
|
941
|
+
|
|
942
|
+
if (!hasItem) {
|
|
943
|
+
if (rangeStart === -1) {
|
|
944
|
+
rangeStart = i;
|
|
945
|
+
}
|
|
946
|
+
} else {
|
|
947
|
+
if (rangeStart !== -1) {
|
|
948
|
+
missingRanges.push({ start: rangeStart, end: i - 1 });
|
|
949
|
+
rangeStart = -1;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// AICODE-NOTE: Add final range if it extends to the end
|
|
955
|
+
if (rangeStart !== -1) {
|
|
956
|
+
missingRanges.push({ start: rangeStart, end: expandedEnd });
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// AICODE-NOTE: Load missing ranges
|
|
960
|
+
for (const range of missingRanges) {
|
|
961
|
+
// AICODE-NOTE: Skip if already loading this range
|
|
962
|
+
const rangeKey = `${range.start}-${range.end}`;
|
|
963
|
+
if (this.loadingRanges.has(rangeKey)) {
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
yield this.loadItemRange(range.start, range.end + 1);
|
|
969
|
+
} catch (error) {
|
|
970
|
+
console.warn('Failed to load item range:', range, error);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Update scroll position
|
|
977
|
+
*/
|
|
978
|
+
updateScrollPosition(position: number): void {
|
|
979
|
+
this.scrollPosition = position;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Update container size
|
|
984
|
+
*/
|
|
985
|
+
updateContainerSize(width: number, height: number): void {
|
|
986
|
+
this.containerSize = { width, height };
|
|
987
|
+
|
|
988
|
+
// Update calculators with new container size
|
|
989
|
+
this.updateLayoutCalculators();
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// AICODE-NOTE: Layout calculator management
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Update layout calculators with current settings
|
|
996
|
+
*/
|
|
997
|
+
private updateLayoutCalculators(): void {
|
|
998
|
+
const { width, height } = this.containerSize;
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
if (width > 0 && height > 0) {
|
|
1003
|
+
// Update grid calculator with very flexible constraints for better auto-calculation
|
|
1004
|
+
// AICODE-NOTE: Prioritize fitting multiple items over exact size matching
|
|
1005
|
+
const minWidth = 120; // Absolute minimum for readability
|
|
1006
|
+
const maxWidth = width * 0.95; // Allow up to 95% of container width
|
|
1007
|
+
|
|
1008
|
+
// console.log('📐 [MODEL] Grid constraints:', {
|
|
1009
|
+
// container: `${width}x${height}`,
|
|
1010
|
+
// minWidth,
|
|
1011
|
+
// maxWidth,
|
|
1012
|
+
// customItemWidth: this.customItemWidth,
|
|
1013
|
+
// itemsPerRow: this.itemsPerRow
|
|
1014
|
+
// });
|
|
1015
|
+
|
|
1016
|
+
if (this.gridCalculator) {
|
|
1017
|
+
this.gridCalculator.updateConfig({
|
|
1018
|
+
containerWidth: width,
|
|
1019
|
+
containerHeight: height,
|
|
1020
|
+
itemsPerRow: this.itemsPerRow,
|
|
1021
|
+
minItemWidth: minWidth,
|
|
1022
|
+
maxItemWidth: maxWidth,
|
|
1023
|
+
aspectRatio: this.customItemWidth / this.customItemHeight
|
|
1024
|
+
});
|
|
1025
|
+
} else {
|
|
1026
|
+
this.gridCalculator = createGridCalculator(width, height, {
|
|
1027
|
+
itemsPerRow: this.itemsPerRow,
|
|
1028
|
+
minItemWidth: minWidth,
|
|
1029
|
+
maxItemWidth: maxWidth,
|
|
1030
|
+
aspectRatio: this.customItemWidth / this.customItemHeight
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Update masonry engines with tight spacing for compact layout
|
|
1035
|
+
const gutter = 4; // Tight spacing for dense masonry layout
|
|
1036
|
+
|
|
1037
|
+
// Calculate optimal column width for masonry layout
|
|
1038
|
+
const targetColumns = this.itemsPerRow === 'auto' ?
|
|
1039
|
+
Math.floor(width / 250) || 3 : // Default to ~250px columns
|
|
1040
|
+
(typeof this.itemsPerRow === 'number' ? this.itemsPerRow : 3);
|
|
1041
|
+
|
|
1042
|
+
const availableWidth = width - (gutter * (targetColumns + 1));
|
|
1043
|
+
const columnWidth = Math.floor(availableWidth / targetColumns);
|
|
1044
|
+
|
|
1045
|
+
if (this.verticalMasonryEngine) {
|
|
1046
|
+
this.verticalMasonryEngine.updateConfig({
|
|
1047
|
+
containerWidth: width,
|
|
1048
|
+
columnWidth,
|
|
1049
|
+
gutter,
|
|
1050
|
+
horizontalOrder: false
|
|
1051
|
+
});
|
|
1052
|
+
} else {
|
|
1053
|
+
this.verticalMasonryEngine = createVerticalMasonry(width, columnWidth, gutter);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (this.horizontalMasonryEngine) {
|
|
1057
|
+
this.horizontalMasonryEngine.updateConfig({
|
|
1058
|
+
containerWidth: width,
|
|
1059
|
+
columnWidth,
|
|
1060
|
+
gutter,
|
|
1061
|
+
horizontalOrder: true
|
|
1062
|
+
});
|
|
1063
|
+
} else {
|
|
1064
|
+
this.horizontalMasonryEngine = createHorizontalMasonry(width, columnWidth, gutter);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Get grid layout for current items
|
|
1071
|
+
*/
|
|
1072
|
+
getGridLayout(): { layout: GridItemLayout[]; totalHeight: number } | null {
|
|
1073
|
+
// Don't call updateLayoutCalculators() here — it modifies observables and
|
|
1074
|
+
// would violate MobX strict mode when called during render.
|
|
1075
|
+
|
|
1076
|
+
if (this.gridCalculator) {
|
|
1077
|
+
const result = this.gridCalculator.calculateLayout(this.items.length);
|
|
1078
|
+
return {
|
|
1079
|
+
layout: result.items,
|
|
1080
|
+
totalHeight: result.totalHeight
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Check whether an item is an actual image that should get variable height
|
|
1089
|
+
* in masonry layout. Non-image items (folders, docs, code files) get uniform height.
|
|
1090
|
+
*/
|
|
1091
|
+
private isImageItem(item: ListItemData): boolean {
|
|
1092
|
+
// Has an explicit aspect ratio set on the item data
|
|
1093
|
+
if (item.aspectRatio) return true;
|
|
1094
|
+
// Has a thumbnail URL (provider-supplied image)
|
|
1095
|
+
if (item.thumbnailUrl) return true;
|
|
1096
|
+
// Has an image URL
|
|
1097
|
+
if (item.imageUrl) return true;
|
|
1098
|
+
// Provider reports a measured aspect ratio for this path
|
|
1099
|
+
if (this.provider.getAspectRatio?.(item.path) !== undefined) return true;
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Get vertical masonry layout for current items
|
|
1105
|
+
*/
|
|
1106
|
+
getVerticalMasonryLayout(): { layout: MasonryItemPosition[]; totalHeight: number } | null {
|
|
1107
|
+
// Don't call updateLayoutCalculators() here — it modifies observables and
|
|
1108
|
+
// would violate MobX strict mode when called during render. The engine is
|
|
1109
|
+
// created by updateContainerSize() which runs in useEffect.
|
|
1110
|
+
|
|
1111
|
+
if (this.verticalMasonryEngine) {
|
|
1112
|
+
// Convert items to masonry data.
|
|
1113
|
+
// Only images get variable height based on aspect ratio.
|
|
1114
|
+
// Non-image items (folders, documents, etc.) get a uniform fixed height.
|
|
1115
|
+
const columnWidth = this.verticalMasonryEngine?.columnWidth || 250;
|
|
1116
|
+
const fixedNonImageHeight = Math.round(columnWidth * 0.75); // 4:3 aspect for uniform tiles
|
|
1117
|
+
|
|
1118
|
+
const masonryItems: MasonryItem[] = this.items.map(item => {
|
|
1119
|
+
const width = columnWidth;
|
|
1120
|
+
|
|
1121
|
+
if (this.isImageItem(item)) {
|
|
1122
|
+
const aspectRatio = item.aspectRatio
|
|
1123
|
+
|| this.provider.getAspectRatio?.(item.path)
|
|
1124
|
+
|| this.generateDeterministicAspectRatio(item.id);
|
|
1125
|
+
const height = Math.round(width / aspectRatio);
|
|
1126
|
+
return { id: item.id, width, height, aspectRatio };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Non-image: fixed uniform height
|
|
1130
|
+
return { id: item.id, width, height: fixedNonImageHeight };
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
const result = this.verticalMasonryEngine.calculateLayout(masonryItems);
|
|
1134
|
+
return {
|
|
1135
|
+
layout: result.items,
|
|
1136
|
+
totalHeight: result.totalHeight
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Get horizontal masonry layout for current items
|
|
1145
|
+
*/
|
|
1146
|
+
getHorizontalMasonryLayout(): { layout: MasonryItemPosition[]; totalHeight: number } | null {
|
|
1147
|
+
// Don't call updateLayoutCalculators() here — same reason as vertical.
|
|
1148
|
+
|
|
1149
|
+
if (this.horizontalMasonryEngine) {
|
|
1150
|
+
// Convert items to masonry data.
|
|
1151
|
+
// Only images get variable height; non-image items get uniform height.
|
|
1152
|
+
const columnWidth = this.horizontalMasonryEngine?.columnWidth || 250;
|
|
1153
|
+
const fixedNonImageHeight = Math.round(columnWidth * 0.75); // 4:3 aspect for uniform tiles
|
|
1154
|
+
|
|
1155
|
+
const masonryItems: MasonryItem[] = this.items.map(item => {
|
|
1156
|
+
const width = columnWidth;
|
|
1157
|
+
|
|
1158
|
+
if (this.isImageItem(item)) {
|
|
1159
|
+
const aspectRatio = item.aspectRatio
|
|
1160
|
+
|| this.provider.getAspectRatio?.(item.path)
|
|
1161
|
+
|| this.generateDeterministicAspectRatio(item.id);
|
|
1162
|
+
const height = Math.round(width / aspectRatio);
|
|
1163
|
+
return { id: item.id, width, height, aspectRatio };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Non-image: fixed uniform height
|
|
1167
|
+
return { id: item.id, width, height: fixedNonImageHeight };
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
const result = this.horizontalMasonryEngine.calculateLayout(masonryItems);
|
|
1171
|
+
return {
|
|
1172
|
+
layout: result.items,
|
|
1173
|
+
totalHeight: result.totalHeight
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Get visible items for virtualization
|
|
1182
|
+
*/
|
|
1183
|
+
getVisibleGridItems(scrollTop: number, viewportHeight: number): GridItemLayout[] {
|
|
1184
|
+
if (!this.gridCalculator) return [];
|
|
1185
|
+
|
|
1186
|
+
const result = this.gridCalculator.calculateVisibleItems(
|
|
1187
|
+
this.items.length,
|
|
1188
|
+
scrollTop,
|
|
1189
|
+
viewportHeight
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
return result.visibleItems;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Get visible vertical masonry items for virtualization
|
|
1197
|
+
*/
|
|
1198
|
+
getVisibleVerticalMasonryItems(scrollTop: number, viewportHeight: number): MasonryItemPosition[] {
|
|
1199
|
+
const layout = this.getVerticalMasonryLayout();
|
|
1200
|
+
if (!layout) return [];
|
|
1201
|
+
|
|
1202
|
+
// Filter items that are visible in the viewport
|
|
1203
|
+
return layout.layout.filter(item => {
|
|
1204
|
+
const itemTop = item.y;
|
|
1205
|
+
const itemBottom = item.y + item.height;
|
|
1206
|
+
const viewportTop = scrollTop;
|
|
1207
|
+
const viewportBottom = scrollTop + viewportHeight;
|
|
1208
|
+
|
|
1209
|
+
return itemBottom >= viewportTop && itemTop <= viewportBottom;
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Get visible horizontal masonry items for virtualization
|
|
1215
|
+
*/
|
|
1216
|
+
getVisibleHorizontalMasonryItems(scrollTop: number, viewportHeight: number): MasonryItemPosition[] {
|
|
1217
|
+
const layout = this.getHorizontalMasonryLayout();
|
|
1218
|
+
if (!layout) return [];
|
|
1219
|
+
|
|
1220
|
+
// Filter items that are visible in the viewport
|
|
1221
|
+
return layout.layout.filter(item => {
|
|
1222
|
+
const itemTop = item.y;
|
|
1223
|
+
const itemBottom = item.y + item.height;
|
|
1224
|
+
const viewportTop = scrollTop;
|
|
1225
|
+
const viewportBottom = scrollTop + viewportHeight;
|
|
1226
|
+
|
|
1227
|
+
return itemBottom >= viewportTop && itemTop <= viewportBottom;
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Get visible masonry items for virtualization (unified method)
|
|
1233
|
+
*/
|
|
1234
|
+
getVisibleMasonryItems(scrollTop: number, viewportHeight: number, isHorizontal: boolean = false, overscan: number = 100): MasonryItemPosition[] {
|
|
1235
|
+
const layout = isHorizontal ? this.getHorizontalMasonryLayout() : this.getVerticalMasonryLayout();
|
|
1236
|
+
if (!layout) return [];
|
|
1237
|
+
|
|
1238
|
+
// Add overscan to viewport for smoother scrolling
|
|
1239
|
+
const viewportTop = scrollTop - overscan;
|
|
1240
|
+
const viewportBottom = scrollTop + viewportHeight + overscan;
|
|
1241
|
+
|
|
1242
|
+
// Filter items that intersect with the extended viewport
|
|
1243
|
+
return layout.layout.filter(item => {
|
|
1244
|
+
const itemTop = item.y;
|
|
1245
|
+
const itemBottom = item.y + item.height;
|
|
1246
|
+
|
|
1247
|
+
return itemBottom >= viewportTop && itemTop <= viewportBottom;
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// =====================
|
|
1252
|
+
// Imperative Methods
|
|
1253
|
+
// =====================
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Ensure item is visible (scroll to item)
|
|
1257
|
+
*/
|
|
1258
|
+
ensureItemVisible(itemId: string): boolean {
|
|
1259
|
+
const index = this.items.findIndex(item => item.id === itemId);
|
|
1260
|
+
if (index === -1) return false;
|
|
1261
|
+
|
|
1262
|
+
this.scrollToItemId = itemId;
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
clearScrollToItem(): void {
|
|
1267
|
+
this.scrollToItemId = null;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Get item by ID
|
|
1272
|
+
*/
|
|
1273
|
+
getItem(itemId: string): ListItemData | undefined {
|
|
1274
|
+
return this.itemMap.get(itemId);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Get item index
|
|
1279
|
+
*/
|
|
1280
|
+
getItemIndex(itemId: string): number {
|
|
1281
|
+
return this.items.findIndex(item => item.id === itemId);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Resolve thumbnail URL for an item via provider cache.
|
|
1286
|
+
* Returns blob URL when ready, undefined while loading.
|
|
1287
|
+
* MobX observer components re-render when the URL becomes available.
|
|
1288
|
+
*/
|
|
1289
|
+
resolveThumbnailUrl(item: ListItemData): string | undefined {
|
|
1290
|
+
return this.provider.resolveThumbnailUrl?.(item);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// AICODE-NOTE: Should have functionality like ensure visible etc.
|
|
1294
|
+
// AICODE-NOTE: Should have support for selection, focus, etc.
|
|
1295
|
+
// AICODE-NOTE: Use similar approach as in TreeComponent for context menu, icons, multi-select, etc.
|
|
1296
|
+
// AICODE-NOTE: Should support custom item drawing
|
|
1297
|
+
// AICODE-NOTE: Should have ellipsis for long text
|
|
1298
|
+
|
|
1299
|
+
// AICODE-NOTE: Should have different sizes for thumbnail(grid) view
|
|
1300
|
+
// AICODE-NOTE: thumbnail should be from N per line until 1 per line
|
|
1301
|
+
}
|