@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,709 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { observer } from 'mobx-react-lite';
|
|
3
|
+
import {
|
|
4
|
+
layoutSquarefy,
|
|
5
|
+
createSurfaces,
|
|
6
|
+
drawToImageData,
|
|
7
|
+
Rectangle,
|
|
8
|
+
} from '@cushiontreemap/core';
|
|
9
|
+
import type { INode, Color } from '@cushiontreemap/core';
|
|
10
|
+
import { ListItemsModel } from '../models/ListItemsModel';
|
|
11
|
+
import {
|
|
12
|
+
TreemapModel,
|
|
13
|
+
formatBytes,
|
|
14
|
+
getColorForExtension,
|
|
15
|
+
getHexColorForExtension,
|
|
16
|
+
EXTENSION_LEGEND,
|
|
17
|
+
EXTENSION_CATEGORIES,
|
|
18
|
+
} from '../models/TreemapModel';
|
|
19
|
+
import type { ListItemData } from '../types/ListTypes';
|
|
20
|
+
import type { TreemapNodeData, TreemapScanProgress } from '../providers/ListItemsProvider';
|
|
21
|
+
|
|
22
|
+
/** Node metadata for overlay tiles */
|
|
23
|
+
export interface TileInfo {
|
|
24
|
+
path: string;
|
|
25
|
+
name: string;
|
|
26
|
+
size?: number;
|
|
27
|
+
isDirectory: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TreemapViewProps {
|
|
31
|
+
model: ListItemsModel;
|
|
32
|
+
treemapModel: TreemapModel;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
className?: string;
|
|
36
|
+
onTileContextMenu?: (info: TileInfo, event: React.MouseEvent) => void;
|
|
37
|
+
onEmptyContextMenu?: (event: React.MouseEvent) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Minimum pixel area for a tile to get an interactive overlay div */
|
|
41
|
+
const MIN_TILE_AREA = 50;
|
|
42
|
+
|
|
43
|
+
/** Minimum dimensions for showing file name label */
|
|
44
|
+
const MIN_LABEL_WIDTH = 60;
|
|
45
|
+
const MIN_LABEL_HEIGHT = 24;
|
|
46
|
+
|
|
47
|
+
/** Minimum dimensions for showing size label */
|
|
48
|
+
const MIN_SIZE_LABEL_WIDTH = 80;
|
|
49
|
+
const MIN_SIZE_LABEL_HEIGHT = 40;
|
|
50
|
+
|
|
51
|
+
/** Base height for legend bar */
|
|
52
|
+
const LEGEND_HEIGHT = 28;
|
|
53
|
+
|
|
54
|
+
/** Height for breadcrumb bar */
|
|
55
|
+
const BREADCRUMB_HEIGHT = 28;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Map extension category labels to their extension sets for legend highlighting.
|
|
59
|
+
*/
|
|
60
|
+
const CATEGORY_EXTENSIONS: Record<string, Set<string>> = {
|
|
61
|
+
'JS/TS': new Set(['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs']),
|
|
62
|
+
'Styles': new Set(['css', 'scss', 'less', 'sass']),
|
|
63
|
+
'Markup': new Set(['html', 'xml', 'svg', 'vue']),
|
|
64
|
+
'Data': new Set(['json', 'yaml', 'yml', 'toml', 'env']),
|
|
65
|
+
'Images': new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico']),
|
|
66
|
+
'Docs': new Set(['md', 'txt', 'pdf', 'doc', 'docx']),
|
|
67
|
+
'Archives': new Set(['zip', 'tar', 'gz', 'br', 'rar', '7z']),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if a file matches a given extension category label.
|
|
72
|
+
*/
|
|
73
|
+
function matchesExtensionCategory(name: string, isDirectory: boolean, category: string): boolean {
|
|
74
|
+
if (category === 'Folders') return isDirectory;
|
|
75
|
+
if (category === 'Other') {
|
|
76
|
+
if (isDirectory) return false;
|
|
77
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
78
|
+
return !EXTENSION_CATEGORIES[ext];
|
|
79
|
+
}
|
|
80
|
+
const exts = CATEGORY_EXTENSIONS[category];
|
|
81
|
+
if (!exts) return false;
|
|
82
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
83
|
+
return exts.has(ext);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compute recursive size for a TreemapNodeData (sum of all descendant file sizes).
|
|
88
|
+
*/
|
|
89
|
+
function computeSize(node: TreemapNodeData): number {
|
|
90
|
+
if (!node.isDirectory || !node.children || node.children.length === 0) {
|
|
91
|
+
return node.size ?? 1;
|
|
92
|
+
}
|
|
93
|
+
return node.children.reduce((sum, child) => sum + computeSize(child), 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build an INode tree from recursive TreemapNodeData.
|
|
98
|
+
* Directories become internal nodes with children; files become leaves.
|
|
99
|
+
*/
|
|
100
|
+
function buildNestedTree(nodes: TreemapNodeData[]): {
|
|
101
|
+
root: INode<string>;
|
|
102
|
+
infoMap: Map<string, TileInfo>;
|
|
103
|
+
} {
|
|
104
|
+
const infoMap = new Map<string, TileInfo>();
|
|
105
|
+
|
|
106
|
+
function convertNode(node: TreemapNodeData): INode<string> {
|
|
107
|
+
const size = computeSize(node);
|
|
108
|
+
infoMap.set(node.path, {
|
|
109
|
+
path: node.path,
|
|
110
|
+
name: node.name,
|
|
111
|
+
size: node.size,
|
|
112
|
+
isDirectory: node.isDirectory,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (node.isDirectory && node.children && node.children.length > 0) {
|
|
116
|
+
const children = node.children
|
|
117
|
+
.map(child => convertNode(child))
|
|
118
|
+
.filter(c => (c.value ?? 0) > 0)
|
|
119
|
+
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
id: node.path,
|
|
123
|
+
value: size,
|
|
124
|
+
children: children.length > 0 ? children : undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
id: node.path,
|
|
130
|
+
value: size,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const children = nodes
|
|
135
|
+
.map(n => convertNode(n))
|
|
136
|
+
.filter(c => (c.value ?? 0) > 0)
|
|
137
|
+
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
|
|
138
|
+
|
|
139
|
+
const root: INode<string> = {
|
|
140
|
+
id: '__root__',
|
|
141
|
+
value: children.reduce((sum, c) => sum + (c.value ?? 0), 0),
|
|
142
|
+
children,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { root, infoMap };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build flat INode tree from ListItemData (fallback when no recursive data).
|
|
150
|
+
*/
|
|
151
|
+
function buildFlatTree(items: ListItemData[]): {
|
|
152
|
+
root: INode<string>;
|
|
153
|
+
infoMap: Map<string, TileInfo>;
|
|
154
|
+
} {
|
|
155
|
+
const infoMap = new Map<string, TileInfo>();
|
|
156
|
+
|
|
157
|
+
const children: INode<string>[] = items
|
|
158
|
+
.filter(item => (item.size ?? 1) > 0)
|
|
159
|
+
.map(item => {
|
|
160
|
+
infoMap.set(item.id, {
|
|
161
|
+
path: item.path,
|
|
162
|
+
name: item.name,
|
|
163
|
+
size: item.size,
|
|
164
|
+
isDirectory: !!item.isDirectory,
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
id: item.id,
|
|
168
|
+
value: item.size ?? 1,
|
|
169
|
+
};
|
|
170
|
+
})
|
|
171
|
+
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
|
|
172
|
+
|
|
173
|
+
const root: INode<string> = {
|
|
174
|
+
id: '__root__',
|
|
175
|
+
value: children.reduce((sum, c) => sum + (c.value ?? 0), 0),
|
|
176
|
+
children,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return { root, infoMap };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find a subtree's children in recursive TreemapNodeData by path.
|
|
184
|
+
* Returns the children of the directory at the given path, or null if not found.
|
|
185
|
+
*/
|
|
186
|
+
function findSubtree(nodes: TreemapNodeData[], path: string): TreemapNodeData[] | null {
|
|
187
|
+
for (const node of nodes) {
|
|
188
|
+
if (node.path === path && node.isDirectory && node.children) {
|
|
189
|
+
return node.children;
|
|
190
|
+
}
|
|
191
|
+
if (node.children) {
|
|
192
|
+
const found = findSubtree(node.children, path);
|
|
193
|
+
if (found) return found;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Find a node by ID in the INode tree */
|
|
200
|
+
function findNode(node: INode<string>, id: string): INode<string> | null {
|
|
201
|
+
if (node.id === id) return node;
|
|
202
|
+
if (node.children) {
|
|
203
|
+
for (const child of node.children) {
|
|
204
|
+
const found = findNode(child, id);
|
|
205
|
+
if (found) return found;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* TreemapView — renders items from ListItemsModel as a cushion-shaded treemap.
|
|
213
|
+
*
|
|
214
|
+
* Uses @cushiontreemap/core to render to a canvas, with transparent overlaid
|
|
215
|
+
* divs for click/hover interactivity.
|
|
216
|
+
*
|
|
217
|
+
* If the provider supports loadTreemapData(), shows recursive subdirectory content.
|
|
218
|
+
*/
|
|
219
|
+
export const TreemapView = observer<TreemapViewProps>(({
|
|
220
|
+
model,
|
|
221
|
+
treemapModel,
|
|
222
|
+
width,
|
|
223
|
+
height,
|
|
224
|
+
className = '',
|
|
225
|
+
onTileContextMenu,
|
|
226
|
+
onEmptyContextMenu,
|
|
227
|
+
}) => {
|
|
228
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
229
|
+
|
|
230
|
+
// Recursive data loading
|
|
231
|
+
const [recursiveData, setRecursiveData] = useState<TreemapNodeData[] | null>(null);
|
|
232
|
+
const [loading, setLoading] = useState(false);
|
|
233
|
+
const [scanProgress, setScanProgress] = useState<TreemapScanProgress | null>(null);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!model.provider.loadTreemapData) return;
|
|
237
|
+
let cancelled = false;
|
|
238
|
+
setLoading(true);
|
|
239
|
+
setScanProgress(null);
|
|
240
|
+
treemapModel.resetZoom();
|
|
241
|
+
model.provider.loadTreemapData((progress) => {
|
|
242
|
+
if (!cancelled) setScanProgress({ ...progress });
|
|
243
|
+
})
|
|
244
|
+
.then(data => { if (!cancelled) setRecursiveData(data); })
|
|
245
|
+
.catch(err => console.warn('[TreemapView] Failed to load recursive data:', err))
|
|
246
|
+
.finally(() => { if (!cancelled) { setLoading(false); setScanProgress(null); } });
|
|
247
|
+
return () => { cancelled = true; };
|
|
248
|
+
}, [model.provider, model.items]); // reload when items change (path navigation)
|
|
249
|
+
|
|
250
|
+
// Compute chrome height (legend + optional breadcrumb)
|
|
251
|
+
const hasBreadcrumbs = treemapModel.breadcrumbs.length > 0;
|
|
252
|
+
const chromeHeight = LEGEND_HEIGHT + (hasBreadcrumbs ? BREADCRUMB_HEIGHT : 0);
|
|
253
|
+
|
|
254
|
+
// Compute available canvas dimensions (subtract chrome)
|
|
255
|
+
const canvasWidth = Math.max(1, Math.floor(width));
|
|
256
|
+
const canvasHeight = Math.max(1, Math.floor(height - chromeHeight));
|
|
257
|
+
|
|
258
|
+
// When zoomed, find subtree from recursive data
|
|
259
|
+
const effectiveData = useMemo(() => {
|
|
260
|
+
if (!recursiveData || !treemapModel.zoomPath) return recursiveData;
|
|
261
|
+
const subtree = findSubtree(recursiveData, treemapModel.zoomPath);
|
|
262
|
+
return subtree ?? recursiveData;
|
|
263
|
+
}, [recursiveData, treemapModel.zoomPath]);
|
|
264
|
+
|
|
265
|
+
// Build the tree — prefer recursive data if available
|
|
266
|
+
const { root, infoMap, rects } = useMemo(() => {
|
|
267
|
+
const items = effectiveData ?? model.items;
|
|
268
|
+
if ((!effectiveData && model.items.length === 0) || (effectiveData && effectiveData.length === 0)) {
|
|
269
|
+
return {
|
|
270
|
+
root: null,
|
|
271
|
+
infoMap: new Map<string, TileInfo>(),
|
|
272
|
+
rects: new Map<string, Rectangle>(),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const { root: r, infoMap: im } = effectiveData
|
|
277
|
+
? buildNestedTree(effectiveData)
|
|
278
|
+
: buildFlatTree(model.items);
|
|
279
|
+
|
|
280
|
+
if (!r.children || r.children.length === 0) {
|
|
281
|
+
return { root: null, infoMap: im, rects: new Map<string, Rectangle>() };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const rectMap = layoutSquarefy(r, canvasWidth, canvasHeight);
|
|
285
|
+
return { root: r, infoMap: im, rects: rectMap };
|
|
286
|
+
}, [effectiveData, model.items, canvasWidth, canvasHeight]);
|
|
287
|
+
|
|
288
|
+
// Draw the cushion treemap to the canvas
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
const canvas = canvasRef.current;
|
|
291
|
+
if (!canvas || !root || rects.size === 0) return;
|
|
292
|
+
|
|
293
|
+
canvas.width = canvasWidth;
|
|
294
|
+
canvas.height = canvasHeight;
|
|
295
|
+
|
|
296
|
+
const ctx = canvas.getContext('2d');
|
|
297
|
+
if (!ctx) return;
|
|
298
|
+
|
|
299
|
+
const imageData = ctx.createImageData(canvasWidth, canvasHeight);
|
|
300
|
+
|
|
301
|
+
const surfaces = createSurfaces(root, rects);
|
|
302
|
+
|
|
303
|
+
const getColor = (node: INode<string>): Color => {
|
|
304
|
+
if (node.id === '__root__') return [128, 128, 128, 255];
|
|
305
|
+
const info = infoMap.get(node.id);
|
|
306
|
+
if (!info) return [156, 163, 175, 255];
|
|
307
|
+
return getColorForExtension(info.name, info.isDirectory);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
drawToImageData(root, rects, surfaces, imageData, getColor, {
|
|
311
|
+
Ia: 40,
|
|
312
|
+
Is: 215,
|
|
313
|
+
Lx: 0.09759,
|
|
314
|
+
Ly: 0.19518,
|
|
315
|
+
Lz: 0.9759,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
ctx.putImageData(imageData, 0, 0);
|
|
319
|
+
|
|
320
|
+
// Draw border lines between tiles
|
|
321
|
+
ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)';
|
|
322
|
+
ctx.lineWidth = 1;
|
|
323
|
+
for (const [id, rect] of rects) {
|
|
324
|
+
if (id === '__root__') continue;
|
|
325
|
+
const x = Math.round(rect.x0) + 0.5;
|
|
326
|
+
const y = Math.round(rect.y0) + 0.5;
|
|
327
|
+
const w = Math.round(rect.x1 - rect.x0);
|
|
328
|
+
const h = Math.round(rect.y1 - rect.y0);
|
|
329
|
+
if (w > 2 && h > 2) {
|
|
330
|
+
ctx.strokeRect(x, y, w, h);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}, [root, rects, infoMap, canvasWidth, canvasHeight]);
|
|
334
|
+
|
|
335
|
+
// Build overlay tile data — only for leaf nodes (visible tiles)
|
|
336
|
+
const tiles = useMemo(() => {
|
|
337
|
+
if (!root || rects.size === 0) return [];
|
|
338
|
+
|
|
339
|
+
const result: Array<{
|
|
340
|
+
id: string;
|
|
341
|
+
info: TileInfo;
|
|
342
|
+
x: number;
|
|
343
|
+
y: number;
|
|
344
|
+
w: number;
|
|
345
|
+
h: number;
|
|
346
|
+
}> = [];
|
|
347
|
+
|
|
348
|
+
for (const [id, rect] of rects) {
|
|
349
|
+
if (id === '__root__') continue;
|
|
350
|
+
const info = infoMap.get(id);
|
|
351
|
+
if (!info) continue;
|
|
352
|
+
|
|
353
|
+
const x = rect.x0;
|
|
354
|
+
const y = rect.y0;
|
|
355
|
+
const w = rect.x1 - rect.x0;
|
|
356
|
+
const h = rect.y1 - rect.y0;
|
|
357
|
+
|
|
358
|
+
if (w * h < MIN_TILE_AREA) continue;
|
|
359
|
+
|
|
360
|
+
// Skip internal nodes (directories with children that got subdivided)
|
|
361
|
+
// Only show leaf nodes as interactive tiles
|
|
362
|
+
const node = findNode(root, id);
|
|
363
|
+
if (node && node.children && node.children.length > 0) continue;
|
|
364
|
+
|
|
365
|
+
result.push({ id, info, x, y, w, h });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return result;
|
|
369
|
+
}, [root, rects, infoMap]);
|
|
370
|
+
|
|
371
|
+
// Event handlers
|
|
372
|
+
const handleTileClick = (info: TileInfo, event: React.MouseEvent) => {
|
|
373
|
+
// For directories, zoom in on single click (instead of navigating away)
|
|
374
|
+
if (info.isDirectory) {
|
|
375
|
+
treemapModel.zoomIn(info.path);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// For files, select
|
|
379
|
+
const item = model.items.find(i => i.path === info.path || i.id === info.path);
|
|
380
|
+
if (item) {
|
|
381
|
+
model.selectItem(item, {
|
|
382
|
+
ctrl: event.ctrlKey || event.metaKey,
|
|
383
|
+
shift: event.shiftKey,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const handleTileDoubleClick = (info: TileInfo) => {
|
|
389
|
+
if (!model.provider.onItemDoubleClick) return;
|
|
390
|
+
const item = model.items.find(i => i.path === info.path || i.id === info.path);
|
|
391
|
+
if (item) {
|
|
392
|
+
model.provider.onItemDoubleClick(item);
|
|
393
|
+
} else {
|
|
394
|
+
// For recursive items not in the flat list, create a fake item
|
|
395
|
+
const fakeItem: ListItemData = {
|
|
396
|
+
id: info.path,
|
|
397
|
+
name: info.name,
|
|
398
|
+
path: info.path,
|
|
399
|
+
type: info.isDirectory ? 'directory' : 'file',
|
|
400
|
+
isDirectory: info.isDirectory,
|
|
401
|
+
size: info.size,
|
|
402
|
+
};
|
|
403
|
+
model.provider.onItemDoubleClick(fakeItem);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const handleTileContextMenu = (info: TileInfo, event: React.MouseEvent) => {
|
|
408
|
+
event.preventDefault();
|
|
409
|
+
event.stopPropagation();
|
|
410
|
+
onTileContextMenu?.(info, event);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const handleEmptyContextMenu = (event: React.MouseEvent) => {
|
|
414
|
+
event.preventDefault();
|
|
415
|
+
onEmptyContextMenu?.(event);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const handleTileMouseEnter = (id: string) => {
|
|
419
|
+
treemapModel.setHoveredPath(id);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const handleTileMouseLeave = () => {
|
|
423
|
+
treemapModel.setHoveredPath(null);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// Tooltip data
|
|
427
|
+
const hoveredInfo = treemapModel.hoveredPath
|
|
428
|
+
? infoMap.get(treemapModel.hoveredPath)
|
|
429
|
+
: null;
|
|
430
|
+
const hoveredRect = treemapModel.hoveredPath
|
|
431
|
+
? rects.get(treemapModel.hoveredPath)
|
|
432
|
+
: null;
|
|
433
|
+
|
|
434
|
+
const totalSize = useMemo(() => root?.value ?? 0, [root]);
|
|
435
|
+
|
|
436
|
+
// Extension highlighting
|
|
437
|
+
const highlighted = treemapModel.highlightedExtension;
|
|
438
|
+
|
|
439
|
+
if (loading) {
|
|
440
|
+
return (
|
|
441
|
+
<div className={`flex items-center justify-center text-muted-foreground ${className}`} style={{ width, height }}>
|
|
442
|
+
<div className="flex flex-col items-center gap-3">
|
|
443
|
+
<div className="w-6 h-6 border-2 border-muted-foreground/40 border-t-muted-foreground rounded-full animate-spin" />
|
|
444
|
+
<p className="text-sm font-medium">Scanning directories...</p>
|
|
445
|
+
{scanProgress && (
|
|
446
|
+
<div className="flex items-center gap-4 text-xs">
|
|
447
|
+
<span>{scanProgress.directoriesScanned} folders</span>
|
|
448
|
+
<span>{scanProgress.filesFound} files</span>
|
|
449
|
+
{scanProgress.totalSize > 0 && (
|
|
450
|
+
<span>{formatBytes(scanProgress.totalSize)}</span>
|
|
451
|
+
)}
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (model.items.length === 0 && !effectiveData) {
|
|
460
|
+
return (
|
|
461
|
+
<div className={`flex items-center justify-center text-muted-foreground ${className}`} style={{ width, height }}>
|
|
462
|
+
<p className="text-sm">No items to display</p>
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return (
|
|
468
|
+
<div className={`flex flex-col select-none ${className}`} style={{ width, height }}>
|
|
469
|
+
{/* Legend bar — interactive: click to highlight extension category */}
|
|
470
|
+
<div className="flex items-center gap-3 px-3 py-1 border-b bg-background/80 flex-shrink-0 overflow-x-auto" style={{ height: LEGEND_HEIGHT }}>
|
|
471
|
+
<span className="text-xs font-medium text-muted-foreground whitespace-nowrap">Filter:</span>
|
|
472
|
+
{EXTENSION_LEGEND.map(({ label, color }) => {
|
|
473
|
+
const isActive = highlighted === label;
|
|
474
|
+
return (
|
|
475
|
+
<button
|
|
476
|
+
key={label}
|
|
477
|
+
type="button"
|
|
478
|
+
onClick={() => treemapModel.setHighlightedExtension(label)}
|
|
479
|
+
className={`flex items-center gap-1 whitespace-nowrap rounded px-1 py-0.5 transition-colors ${
|
|
480
|
+
isActive
|
|
481
|
+
? 'bg-accent ring-1 ring-accent-foreground/20'
|
|
482
|
+
: 'hover:bg-accent/50'
|
|
483
|
+
}`}
|
|
484
|
+
>
|
|
485
|
+
<div
|
|
486
|
+
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
|
487
|
+
style={{ backgroundColor: color }}
|
|
488
|
+
/>
|
|
489
|
+
<span className={`text-[10px] ${isActive ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>
|
|
490
|
+
{label}
|
|
491
|
+
</span>
|
|
492
|
+
</button>
|
|
493
|
+
);
|
|
494
|
+
})}
|
|
495
|
+
<div className="ml-auto text-[10px] text-muted-foreground whitespace-nowrap">
|
|
496
|
+
{infoMap.size} items • {formatBytes(totalSize)}
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
{/* Breadcrumb bar — only when zoomed */}
|
|
501
|
+
{hasBreadcrumbs && (
|
|
502
|
+
<div className="flex items-center gap-1 px-3 border-b bg-background/60 flex-shrink-0 overflow-x-auto" style={{ height: BREADCRUMB_HEIGHT }}>
|
|
503
|
+
<button
|
|
504
|
+
type="button"
|
|
505
|
+
onClick={() => treemapModel.resetZoom()}
|
|
506
|
+
className="text-[11px] text-primary hover:underline"
|
|
507
|
+
>
|
|
508
|
+
Root
|
|
509
|
+
</button>
|
|
510
|
+
{treemapModel.breadcrumbs.map((crumb) => (
|
|
511
|
+
<React.Fragment key={crumb.path}>
|
|
512
|
+
<span className="text-[10px] text-muted-foreground">/</span>
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
onClick={() => treemapModel.zoomIn(crumb.path)}
|
|
516
|
+
className={`text-[11px] ${
|
|
517
|
+
crumb.path === treemapModel.zoomPath
|
|
518
|
+
? 'text-foreground font-medium'
|
|
519
|
+
: 'text-primary hover:underline'
|
|
520
|
+
}`}
|
|
521
|
+
>
|
|
522
|
+
{crumb.name}
|
|
523
|
+
</button>
|
|
524
|
+
</React.Fragment>
|
|
525
|
+
))}
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
528
|
+
|
|
529
|
+
{/* Treemap canvas + overlay container */}
|
|
530
|
+
<div
|
|
531
|
+
className="relative flex-1 overflow-hidden"
|
|
532
|
+
style={{ width: canvasWidth, height: canvasHeight }}
|
|
533
|
+
onContextMenu={handleEmptyContextMenu}
|
|
534
|
+
>
|
|
535
|
+
{/* Canvas layer — the cushion-shaded treemap */}
|
|
536
|
+
<canvas
|
|
537
|
+
ref={canvasRef}
|
|
538
|
+
width={canvasWidth}
|
|
539
|
+
height={canvasHeight}
|
|
540
|
+
className="absolute inset-0"
|
|
541
|
+
style={{ imageRendering: 'auto' }}
|
|
542
|
+
/>
|
|
543
|
+
|
|
544
|
+
{/* Interactive overlay layer */}
|
|
545
|
+
{tiles.map(({ id, info, x, y, w, h }) => {
|
|
546
|
+
const isSelected = model.isItemSelected(info.path);
|
|
547
|
+
const isHovered = treemapModel.hoveredPath === id;
|
|
548
|
+
const showLabel = w >= MIN_LABEL_WIDTH && h >= MIN_LABEL_HEIGHT;
|
|
549
|
+
const showSize = w >= MIN_SIZE_LABEL_WIDTH && h >= MIN_SIZE_LABEL_HEIGHT;
|
|
550
|
+
|
|
551
|
+
// Extension highlighting: dim non-matching tiles
|
|
552
|
+
const isDimmed = highlighted != null && !matchesExtensionCategory(info.name, info.isDirectory, highlighted);
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
<div
|
|
556
|
+
key={id}
|
|
557
|
+
className="absolute cursor-pointer"
|
|
558
|
+
style={{
|
|
559
|
+
left: x,
|
|
560
|
+
top: y,
|
|
561
|
+
width: w,
|
|
562
|
+
height: h,
|
|
563
|
+
opacity: isDimmed ? 0.3 : 1,
|
|
564
|
+
transition: 'opacity 0.15s ease',
|
|
565
|
+
}}
|
|
566
|
+
onClick={(e) => handleTileClick(info, e)}
|
|
567
|
+
onDoubleClick={() => handleTileDoubleClick(info)}
|
|
568
|
+
onContextMenu={(e) => handleTileContextMenu(info, e)}
|
|
569
|
+
onMouseEnter={() => handleTileMouseEnter(id)}
|
|
570
|
+
onMouseLeave={handleTileMouseLeave}
|
|
571
|
+
aria-label={`${info.name}${info.size ? ` (${formatBytes(info.size)})` : ''}`}
|
|
572
|
+
>
|
|
573
|
+
{/* Selection / hover highlight */}
|
|
574
|
+
{(isSelected || isHovered) && (
|
|
575
|
+
<div
|
|
576
|
+
className="absolute inset-0 pointer-events-none"
|
|
577
|
+
style={{
|
|
578
|
+
backgroundColor: isSelected
|
|
579
|
+
? 'rgba(255, 255, 255, 0.25)'
|
|
580
|
+
: 'rgba(255, 255, 255, 0.12)',
|
|
581
|
+
outline: isSelected
|
|
582
|
+
? '2px solid rgba(59, 130, 246, 0.8)'
|
|
583
|
+
: '1px solid rgba(255, 255, 255, 0.4)',
|
|
584
|
+
outlineOffset: isSelected ? '-2px' : '-1px',
|
|
585
|
+
zIndex: isSelected ? 2 : 1,
|
|
586
|
+
}}
|
|
587
|
+
/>
|
|
588
|
+
)}
|
|
589
|
+
|
|
590
|
+
{/* Text label overlay */}
|
|
591
|
+
{showLabel && treemapModel.showLabels && (
|
|
592
|
+
<div
|
|
593
|
+
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none overflow-hidden px-1"
|
|
594
|
+
style={{ zIndex: 3 }}
|
|
595
|
+
>
|
|
596
|
+
<span
|
|
597
|
+
className="text-white font-medium truncate max-w-full text-center leading-tight"
|
|
598
|
+
style={{
|
|
599
|
+
fontSize: w > 150 && h > 60 ? '12px' : '10px',
|
|
600
|
+
textShadow: '0 1px 3px rgba(0,0,0,0.8), 0 0px 1px rgba(0,0,0,0.9)',
|
|
601
|
+
}}
|
|
602
|
+
>
|
|
603
|
+
{info.name}
|
|
604
|
+
</span>
|
|
605
|
+
{showSize && treemapModel.showSizes && info.size != null && info.size > 0 && (
|
|
606
|
+
<span
|
|
607
|
+
className="text-white/80 truncate max-w-full text-center leading-tight"
|
|
608
|
+
style={{
|
|
609
|
+
fontSize: '9px',
|
|
610
|
+
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
|
|
611
|
+
}}
|
|
612
|
+
>
|
|
613
|
+
{formatBytes(info.size)}
|
|
614
|
+
</span>
|
|
615
|
+
)}
|
|
616
|
+
</div>
|
|
617
|
+
)}
|
|
618
|
+
</div>
|
|
619
|
+
);
|
|
620
|
+
})}
|
|
621
|
+
|
|
622
|
+
{/* Tooltip */}
|
|
623
|
+
{hoveredInfo && hoveredRect && (
|
|
624
|
+
<TreemapTooltip
|
|
625
|
+
info={hoveredInfo}
|
|
626
|
+
rect={hoveredRect}
|
|
627
|
+
canvasWidth={canvasWidth}
|
|
628
|
+
canvasHeight={canvasHeight}
|
|
629
|
+
totalSize={totalSize}
|
|
630
|
+
/>
|
|
631
|
+
)}
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
TreemapView.displayName = 'TreemapView';
|
|
638
|
+
|
|
639
|
+
/** Floating tooltip for hovered treemap tile */
|
|
640
|
+
const TreemapTooltip = observer<{
|
|
641
|
+
info: TileInfo;
|
|
642
|
+
rect: Rectangle;
|
|
643
|
+
canvasWidth: number;
|
|
644
|
+
canvasHeight: number;
|
|
645
|
+
totalSize: number;
|
|
646
|
+
}>(({ info, rect, canvasWidth, canvasHeight, totalSize }) => {
|
|
647
|
+
const centerX = (rect.x0 + rect.x1) / 2;
|
|
648
|
+
const centerY = rect.y0;
|
|
649
|
+
|
|
650
|
+
const tooltipWidth = 200;
|
|
651
|
+
const tooltipHeight = 80;
|
|
652
|
+
|
|
653
|
+
let left = centerX - tooltipWidth / 2;
|
|
654
|
+
let top = centerY - tooltipHeight - 8;
|
|
655
|
+
|
|
656
|
+
if (left < 4) left = 4;
|
|
657
|
+
if (left + tooltipWidth > canvasWidth - 4) left = canvasWidth - tooltipWidth - 4;
|
|
658
|
+
if (top < 4) {
|
|
659
|
+
top = rect.y1 + 8;
|
|
660
|
+
}
|
|
661
|
+
if (top + tooltipHeight > canvasHeight - 4) {
|
|
662
|
+
top = canvasHeight - tooltipHeight - 4;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const ext = info.name.split('.').pop()?.toLowerCase() ?? '';
|
|
666
|
+
const percentage = totalSize > 0 && info.size
|
|
667
|
+
? ((info.size / totalSize) * 100).toFixed(1)
|
|
668
|
+
: null;
|
|
669
|
+
const colorHex = getHexColorForExtension(info.name, info.isDirectory);
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<div
|
|
673
|
+
className="absolute z-50 pointer-events-none rounded-md border bg-popover/95 px-3 py-2 shadow-lg backdrop-blur-sm"
|
|
674
|
+
style={{
|
|
675
|
+
left,
|
|
676
|
+
top,
|
|
677
|
+
width: tooltipWidth,
|
|
678
|
+
}}
|
|
679
|
+
>
|
|
680
|
+
<div className="flex items-center gap-2 mb-1">
|
|
681
|
+
<div
|
|
682
|
+
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
|
683
|
+
style={{ backgroundColor: colorHex }}
|
|
684
|
+
/>
|
|
685
|
+
<span className="text-xs font-medium text-foreground truncate">{info.name}</span>
|
|
686
|
+
</div>
|
|
687
|
+
<div className="text-[10px] text-muted-foreground space-y-0.5">
|
|
688
|
+
<div className="flex justify-between">
|
|
689
|
+
<span>Type</span>
|
|
690
|
+
<span>{info.isDirectory ? 'Directory' : (ext ? `.${ext}` : 'File')}</span>
|
|
691
|
+
</div>
|
|
692
|
+
{info.size != null && (
|
|
693
|
+
<div className="flex justify-between">
|
|
694
|
+
<span>Size</span>
|
|
695
|
+
<span>{formatBytes(info.size)}</span>
|
|
696
|
+
</div>
|
|
697
|
+
)}
|
|
698
|
+
{percentage && (
|
|
699
|
+
<div className="flex justify-between">
|
|
700
|
+
<span>Share</span>
|
|
701
|
+
<span>{percentage}%</span>
|
|
702
|
+
</div>
|
|
703
|
+
)}
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
TreemapTooltip.displayName = 'TreemapTooltip';
|