@weng-lab/genomebrowser-ui 0.2.2 → 0.2.3
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/.env.local +1 -1
- package/dist/TrackSelect/Folders/biosamples/data/human.json.d.ts +57141 -57141
- package/dist/TrackSelect/Folders/biosamples/data/mouse.json.d.ts +10394 -10394
- package/dist/TrackSelect/Folders/genes/data/human.json.d.ts +7 -7
- package/dist/TrackSelect/Folders/genes/data/mouse.json.d.ts +7 -7
- package/dist/genomebrowser-ui.es.js +9 -5
- package/dist/genomebrowser-ui.es.js.map +1 -1
- package/eslint.config.js +30 -30
- package/index.html +14 -14
- package/package.json +1 -1
- package/src/TrackSelect/DataGrid/DataGridWrapper.tsx +137 -137
- package/src/TrackSelect/DataGrid/DefaultGroupingCell.tsx +64 -64
- package/src/TrackSelect/Dialogs/ClearDialog.tsx +63 -63
- package/src/TrackSelect/Dialogs/LimitDialog.tsx +33 -33
- package/src/TrackSelect/Dialogs/ResetDialog.tsx +43 -43
- package/src/TrackSelect/FolderList/Breadcrumb.tsx +38 -38
- package/src/TrackSelect/FolderList/FolderCard.tsx +51 -51
- package/src/TrackSelect/FolderList/FolderList.tsx +47 -47
- package/src/TrackSelect/Folders/NEW.md +929 -929
- package/src/TrackSelect/Folders/biosamples/data/formatBiosamples.go +254 -254
- package/src/TrackSelect/Folders/biosamples/data/human.json +57141 -57141
- package/src/TrackSelect/Folders/biosamples/data/mouse.json +10394 -10394
- package/src/TrackSelect/Folders/biosamples/human.ts +17 -17
- package/src/TrackSelect/Folders/biosamples/mouse.ts +17 -17
- package/src/TrackSelect/Folders/biosamples/shared/AssayToggle.tsx +78 -78
- package/src/TrackSelect/Folders/biosamples/shared/BiosampleGroupingCell.tsx +146 -146
- package/src/TrackSelect/Folders/biosamples/shared/BiosampleTreeItem.tsx +15 -15
- package/src/TrackSelect/Folders/biosamples/shared/columns.tsx +165 -165
- package/src/TrackSelect/Folders/biosamples/shared/constants.tsx +116 -116
- package/src/TrackSelect/Folders/biosamples/shared/createFolder.ts +116 -116
- package/src/TrackSelect/Folders/biosamples/shared/treeBuilder.ts +224 -224
- package/src/TrackSelect/Folders/biosamples/shared/types.ts +48 -48
- package/src/TrackSelect/Folders/genes/data/human.json +7 -7
- package/src/TrackSelect/Folders/genes/data/mouse.json +7 -7
- package/src/TrackSelect/Folders/genes/human.ts +16 -16
- package/src/TrackSelect/Folders/genes/mouse.ts +16 -16
- package/src/TrackSelect/Folders/genes/shared/columns.tsx +42 -42
- package/src/TrackSelect/Folders/genes/shared/createFolder.ts +68 -68
- package/src/TrackSelect/Folders/genes/shared/treeBuilder.ts +45 -45
- package/src/TrackSelect/Folders/genes/shared/types.ts +29 -29
- package/src/TrackSelect/Folders/index.ts +30 -30
- package/src/TrackSelect/Folders/types.ts +106 -106
- package/src/TrackSelect/TrackSelect.tsx +11 -7
- package/src/TrackSelect/TreeView/CustomTreeItem.tsx +214 -214
- package/src/TrackSelect/TreeView/TreeViewWrapper.tsx +145 -145
- package/src/TrackSelect/store.ts +117 -117
- package/src/TrackSelect/types.ts +121 -121
- package/src/lib.ts +13 -13
- package/src/vite-env.d.ts +1 -1
- package/test/main.tsx +369 -369
- package/tsconfig.app.json +25 -25
- package/tsconfig.json +7 -7
- package/tsconfig.node.json +25 -25
- package/vite.config.ts +66 -66
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
import { Avatar, Box, Paper, Typography } from "@mui/material";
|
|
2
|
-
import { RichTreeView, TreeViewBaseItem } from "@mui/x-tree-view";
|
|
3
|
-
import { useEffect, useMemo, useState } from "react";
|
|
4
|
-
import {
|
|
5
|
-
CustomTreeItemProps,
|
|
6
|
-
ExtendedTreeItemProps,
|
|
7
|
-
FolderTreeConfig,
|
|
8
|
-
TreeViewWrapperProps,
|
|
9
|
-
} from "../types";
|
|
10
|
-
import { CustomTreeItem } from "./CustomTreeItem";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Recursively collects all item IDs that have children (expandable items)
|
|
14
|
-
*/
|
|
15
|
-
function getAllExpandableItemIds(
|
|
16
|
-
items: TreeViewBaseItem<ExtendedTreeItemProps>[],
|
|
17
|
-
): string[] {
|
|
18
|
-
const ids: string[] = [];
|
|
19
|
-
for (const item of items) {
|
|
20
|
-
if (item.children && item.children.length > 0) {
|
|
21
|
-
ids.push(item.id);
|
|
22
|
-
ids.push(...getAllExpandableItemIds(item.children));
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return ids;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Internal component that renders a single folder's tree with its own expanded state.
|
|
30
|
-
*/
|
|
31
|
-
function FolderTree({
|
|
32
|
-
items,
|
|
33
|
-
TreeItemComponent,
|
|
34
|
-
onRemove,
|
|
35
|
-
}: {
|
|
36
|
-
items: FolderTreeConfig["items"];
|
|
37
|
-
TreeItemComponent: FolderTreeConfig["TreeItemComponent"];
|
|
38
|
-
onRemove: (item: TreeViewBaseItem<ExtendedTreeItemProps>) => void;
|
|
39
|
-
}) {
|
|
40
|
-
const allExpandableIds = useMemo(
|
|
41
|
-
() => getAllExpandableItemIds(items),
|
|
42
|
-
[items],
|
|
43
|
-
);
|
|
44
|
-
const [expandedItems, setExpandedItems] =
|
|
45
|
-
useState<string[]>(allExpandableIds);
|
|
46
|
-
|
|
47
|
-
// Auto-expand new items when they're added
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
setExpandedItems((prev) => {
|
|
50
|
-
const newIds = allExpandableIds.filter((id) => !prev.includes(id));
|
|
51
|
-
if (newIds.length > 0) {
|
|
52
|
-
return [...prev, ...newIds];
|
|
53
|
-
}
|
|
54
|
-
return prev;
|
|
55
|
-
});
|
|
56
|
-
}, [allExpandableIds]);
|
|
57
|
-
|
|
58
|
-
const handleRemoveTreeItem = (
|
|
59
|
-
item: TreeViewBaseItem<ExtendedTreeItemProps>,
|
|
60
|
-
) => {
|
|
61
|
-
onRemove(item);
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const TreeItem = TreeItemComponent ?? CustomTreeItem;
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
<RichTreeView
|
|
68
|
-
items={items}
|
|
69
|
-
expandedItems={expandedItems}
|
|
70
|
-
onExpandedItemsChange={(_event, ids) => setExpandedItems(ids)}
|
|
71
|
-
slots={{ item: TreeItem }}
|
|
72
|
-
slotProps={{
|
|
73
|
-
item: {
|
|
74
|
-
onRemove: handleRemoveTreeItem,
|
|
75
|
-
} as Partial<CustomTreeItemProps>,
|
|
76
|
-
}}
|
|
77
|
-
sx={{
|
|
78
|
-
ml: 1,
|
|
79
|
-
mr: 1,
|
|
80
|
-
}}
|
|
81
|
-
itemChildrenIndentation={0}
|
|
82
|
-
/>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function TreeViewWrapper({
|
|
87
|
-
folderTrees,
|
|
88
|
-
selectedCount,
|
|
89
|
-
onRemove,
|
|
90
|
-
}: TreeViewWrapperProps) {
|
|
91
|
-
return (
|
|
92
|
-
<Paper
|
|
93
|
-
sx={{
|
|
94
|
-
height: 500,
|
|
95
|
-
width: "100%",
|
|
96
|
-
border: "10px solid",
|
|
97
|
-
borderColor: "grey.200",
|
|
98
|
-
boxSizing: "border-box",
|
|
99
|
-
borderRadius: 2,
|
|
100
|
-
display: "flex",
|
|
101
|
-
flexDirection: "column",
|
|
102
|
-
}}
|
|
103
|
-
>
|
|
104
|
-
<Box
|
|
105
|
-
sx={{
|
|
106
|
-
display: "flex",
|
|
107
|
-
alignItems: "center",
|
|
108
|
-
gap: 1,
|
|
109
|
-
py: 1,
|
|
110
|
-
backgroundColor: "grey.200",
|
|
111
|
-
flexShrink: 0,
|
|
112
|
-
}}
|
|
113
|
-
>
|
|
114
|
-
<Avatar
|
|
115
|
-
sx={{
|
|
116
|
-
width: 30,
|
|
117
|
-
height: 30,
|
|
118
|
-
fontSize: 14,
|
|
119
|
-
fontWeight: "bold",
|
|
120
|
-
bgcolor: "white",
|
|
121
|
-
color: "text.primary",
|
|
122
|
-
}}
|
|
123
|
-
>
|
|
124
|
-
{selectedCount}
|
|
125
|
-
</Avatar>
|
|
126
|
-
<Typography fontWeight="bold">Active Tracks</Typography>
|
|
127
|
-
</Box>
|
|
128
|
-
<Box
|
|
129
|
-
sx={{
|
|
130
|
-
flex: 1,
|
|
131
|
-
overflow: "auto",
|
|
132
|
-
}}
|
|
133
|
-
>
|
|
134
|
-
{folderTrees.map((folderTree) => (
|
|
135
|
-
<FolderTree
|
|
136
|
-
key={folderTree.folderId}
|
|
137
|
-
items={folderTree.items}
|
|
138
|
-
TreeItemComponent={folderTree.TreeItemComponent}
|
|
139
|
-
onRemove={onRemove}
|
|
140
|
-
/>
|
|
141
|
-
))}
|
|
142
|
-
</Box>
|
|
143
|
-
</Paper>
|
|
144
|
-
);
|
|
145
|
-
}
|
|
1
|
+
import { Avatar, Box, Paper, Typography } from "@mui/material";
|
|
2
|
+
import { RichTreeView, TreeViewBaseItem } from "@mui/x-tree-view";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
CustomTreeItemProps,
|
|
6
|
+
ExtendedTreeItemProps,
|
|
7
|
+
FolderTreeConfig,
|
|
8
|
+
TreeViewWrapperProps,
|
|
9
|
+
} from "../types";
|
|
10
|
+
import { CustomTreeItem } from "./CustomTreeItem";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Recursively collects all item IDs that have children (expandable items)
|
|
14
|
+
*/
|
|
15
|
+
function getAllExpandableItemIds(
|
|
16
|
+
items: TreeViewBaseItem<ExtendedTreeItemProps>[],
|
|
17
|
+
): string[] {
|
|
18
|
+
const ids: string[] = [];
|
|
19
|
+
for (const item of items) {
|
|
20
|
+
if (item.children && item.children.length > 0) {
|
|
21
|
+
ids.push(item.id);
|
|
22
|
+
ids.push(...getAllExpandableItemIds(item.children));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return ids;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Internal component that renders a single folder's tree with its own expanded state.
|
|
30
|
+
*/
|
|
31
|
+
function FolderTree({
|
|
32
|
+
items,
|
|
33
|
+
TreeItemComponent,
|
|
34
|
+
onRemove,
|
|
35
|
+
}: {
|
|
36
|
+
items: FolderTreeConfig["items"];
|
|
37
|
+
TreeItemComponent: FolderTreeConfig["TreeItemComponent"];
|
|
38
|
+
onRemove: (item: TreeViewBaseItem<ExtendedTreeItemProps>) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const allExpandableIds = useMemo(
|
|
41
|
+
() => getAllExpandableItemIds(items),
|
|
42
|
+
[items],
|
|
43
|
+
);
|
|
44
|
+
const [expandedItems, setExpandedItems] =
|
|
45
|
+
useState<string[]>(allExpandableIds);
|
|
46
|
+
|
|
47
|
+
// Auto-expand new items when they're added
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
setExpandedItems((prev) => {
|
|
50
|
+
const newIds = allExpandableIds.filter((id) => !prev.includes(id));
|
|
51
|
+
if (newIds.length > 0) {
|
|
52
|
+
return [...prev, ...newIds];
|
|
53
|
+
}
|
|
54
|
+
return prev;
|
|
55
|
+
});
|
|
56
|
+
}, [allExpandableIds]);
|
|
57
|
+
|
|
58
|
+
const handleRemoveTreeItem = (
|
|
59
|
+
item: TreeViewBaseItem<ExtendedTreeItemProps>,
|
|
60
|
+
) => {
|
|
61
|
+
onRemove(item);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const TreeItem = TreeItemComponent ?? CustomTreeItem;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<RichTreeView
|
|
68
|
+
items={items}
|
|
69
|
+
expandedItems={expandedItems}
|
|
70
|
+
onExpandedItemsChange={(_event, ids) => setExpandedItems(ids)}
|
|
71
|
+
slots={{ item: TreeItem }}
|
|
72
|
+
slotProps={{
|
|
73
|
+
item: {
|
|
74
|
+
onRemove: handleRemoveTreeItem,
|
|
75
|
+
} as Partial<CustomTreeItemProps>,
|
|
76
|
+
}}
|
|
77
|
+
sx={{
|
|
78
|
+
ml: 1,
|
|
79
|
+
mr: 1,
|
|
80
|
+
}}
|
|
81
|
+
itemChildrenIndentation={0}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function TreeViewWrapper({
|
|
87
|
+
folderTrees,
|
|
88
|
+
selectedCount,
|
|
89
|
+
onRemove,
|
|
90
|
+
}: TreeViewWrapperProps) {
|
|
91
|
+
return (
|
|
92
|
+
<Paper
|
|
93
|
+
sx={{
|
|
94
|
+
height: 500,
|
|
95
|
+
width: "100%",
|
|
96
|
+
border: "10px solid",
|
|
97
|
+
borderColor: "grey.200",
|
|
98
|
+
boxSizing: "border-box",
|
|
99
|
+
borderRadius: 2,
|
|
100
|
+
display: "flex",
|
|
101
|
+
flexDirection: "column",
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<Box
|
|
105
|
+
sx={{
|
|
106
|
+
display: "flex",
|
|
107
|
+
alignItems: "center",
|
|
108
|
+
gap: 1,
|
|
109
|
+
py: 1,
|
|
110
|
+
backgroundColor: "grey.200",
|
|
111
|
+
flexShrink: 0,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<Avatar
|
|
115
|
+
sx={{
|
|
116
|
+
width: 30,
|
|
117
|
+
height: 30,
|
|
118
|
+
fontSize: 14,
|
|
119
|
+
fontWeight: "bold",
|
|
120
|
+
bgcolor: "white",
|
|
121
|
+
color: "text.primary",
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{selectedCount}
|
|
125
|
+
</Avatar>
|
|
126
|
+
<Typography fontWeight="bold">Active Tracks</Typography>
|
|
127
|
+
</Box>
|
|
128
|
+
<Box
|
|
129
|
+
sx={{
|
|
130
|
+
flex: 1,
|
|
131
|
+
overflow: "auto",
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{folderTrees.map((folderTree) => (
|
|
135
|
+
<FolderTree
|
|
136
|
+
key={folderTree.folderId}
|
|
137
|
+
items={folderTree.items}
|
|
138
|
+
TreeItemComponent={folderTree.TreeItemComponent}
|
|
139
|
+
onRemove={onRemove}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
</Box>
|
|
143
|
+
</Paper>
|
|
144
|
+
);
|
|
145
|
+
}
|
package/src/TrackSelect/store.ts
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
1
|
-
import { create, StoreApi, UseBoundStore } from "zustand";
|
|
2
|
-
import { SelectionAction, SelectionState } from "./types";
|
|
3
|
-
|
|
4
|
-
export type SelectionStoreInstance = UseBoundStore<
|
|
5
|
-
StoreApi<SelectionState & SelectionAction>
|
|
6
|
-
>;
|
|
7
|
-
|
|
8
|
-
const DEFAULT_STORAGE_KEY = "trackSelect_selection";
|
|
9
|
-
|
|
10
|
-
type SerializedSelection = Record<string, string[]>;
|
|
11
|
-
|
|
12
|
-
const serializeSelection = (
|
|
13
|
-
selection: Map<string, Set<string>>,
|
|
14
|
-
): SerializedSelection => {
|
|
15
|
-
const obj: SerializedSelection = {};
|
|
16
|
-
selection.forEach((ids, folderId) => {
|
|
17
|
-
obj[folderId] = Array.from(ids);
|
|
18
|
-
});
|
|
19
|
-
return obj;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const deserializeSelection = (
|
|
23
|
-
data: SerializedSelection,
|
|
24
|
-
): Map<string, Set<string>> => {
|
|
25
|
-
const map = new Map<string, Set<string>>();
|
|
26
|
-
Object.entries(data).forEach(([folderId, ids]) => {
|
|
27
|
-
map.set(folderId, new Set(ids));
|
|
28
|
-
});
|
|
29
|
-
return map;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const loadFromStorage = (
|
|
33
|
-
storageKey: string,
|
|
34
|
-
): Map<string, Set<string>> | undefined => {
|
|
35
|
-
try {
|
|
36
|
-
const stored = sessionStorage.getItem(storageKey);
|
|
37
|
-
if (stored) {
|
|
38
|
-
const parsed = JSON.parse(stored) as SerializedSelection;
|
|
39
|
-
return deserializeSelection(parsed);
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
// Ignore storage errors
|
|
43
|
-
}
|
|
44
|
-
return undefined;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const saveToStorage = (
|
|
48
|
-
selection: Map<string, Set<string>>,
|
|
49
|
-
storageKey: string,
|
|
50
|
-
) => {
|
|
51
|
-
try {
|
|
52
|
-
const serialized = serializeSelection(selection);
|
|
53
|
-
sessionStorage.setItem(storageKey, JSON.stringify(serialized));
|
|
54
|
-
} catch {
|
|
55
|
-
// Ignore storage errors
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const buildSelectionMap = (
|
|
60
|
-
folderIds: string[],
|
|
61
|
-
initialSelection?: Map<string, Set<string>>,
|
|
62
|
-
) => {
|
|
63
|
-
const map = new Map<string, Set<string>>();
|
|
64
|
-
folderIds.forEach((folderId) => {
|
|
65
|
-
const initial = initialSelection?.get(folderId);
|
|
66
|
-
map.set(folderId, initial ? new Set(initial) : new Set<string>());
|
|
67
|
-
});
|
|
68
|
-
return map;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
export function createSelectionStore(
|
|
72
|
-
folderIds: string[],
|
|
73
|
-
storageKey: string = DEFAULT_STORAGE_KEY,
|
|
74
|
-
initialSelection?: Map<string, Set<string>>,
|
|
75
|
-
) {
|
|
76
|
-
const storedSelection = loadFromStorage(storageKey);
|
|
77
|
-
// Storage wins: use stored if exists, else fall back to initialSelection
|
|
78
|
-
const selectedByFolder = buildSelectionMap(
|
|
79
|
-
folderIds,
|
|
80
|
-
storedSelection ?? initialSelection,
|
|
81
|
-
);
|
|
82
|
-
const activeFolderId = folderIds[0] ?? "";
|
|
83
|
-
|
|
84
|
-
const store = create<SelectionState & SelectionAction>((set) => ({
|
|
85
|
-
selectedByFolder,
|
|
86
|
-
activeFolderId,
|
|
87
|
-
clear: (folderId?: string) =>
|
|
88
|
-
set((state) => {
|
|
89
|
-
if (folderId) {
|
|
90
|
-
const next = new Map(state.selectedByFolder);
|
|
91
|
-
next.set(folderId, new Set<string>());
|
|
92
|
-
return { selectedByFolder: next };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const next = new Map<string, Set<string>>();
|
|
96
|
-
state.selectedByFolder.forEach((_value, id) => {
|
|
97
|
-
next.set(id, new Set<string>());
|
|
98
|
-
});
|
|
99
|
-
return { selectedByFolder: next };
|
|
100
|
-
}),
|
|
101
|
-
setActiveFolder: (folderId: string) =>
|
|
102
|
-
set(() => ({ activeFolderId: folderId })),
|
|
103
|
-
setSelection: (folderId: string, ids: Set<string>) =>
|
|
104
|
-
set((state) => {
|
|
105
|
-
const next = new Map(state.selectedByFolder);
|
|
106
|
-
next.set(folderId, new Set(ids));
|
|
107
|
-
return { selectedByFolder: next };
|
|
108
|
-
}),
|
|
109
|
-
}));
|
|
110
|
-
|
|
111
|
-
// Subscribe to changes and persist to storage
|
|
112
|
-
store.subscribe((state) => {
|
|
113
|
-
saveToStorage(state.selectedByFolder, storageKey);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
return store;
|
|
117
|
-
}
|
|
1
|
+
import { create, StoreApi, UseBoundStore } from "zustand";
|
|
2
|
+
import { SelectionAction, SelectionState } from "./types";
|
|
3
|
+
|
|
4
|
+
export type SelectionStoreInstance = UseBoundStore<
|
|
5
|
+
StoreApi<SelectionState & SelectionAction>
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
const DEFAULT_STORAGE_KEY = "trackSelect_selection";
|
|
9
|
+
|
|
10
|
+
type SerializedSelection = Record<string, string[]>;
|
|
11
|
+
|
|
12
|
+
const serializeSelection = (
|
|
13
|
+
selection: Map<string, Set<string>>,
|
|
14
|
+
): SerializedSelection => {
|
|
15
|
+
const obj: SerializedSelection = {};
|
|
16
|
+
selection.forEach((ids, folderId) => {
|
|
17
|
+
obj[folderId] = Array.from(ids);
|
|
18
|
+
});
|
|
19
|
+
return obj;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const deserializeSelection = (
|
|
23
|
+
data: SerializedSelection,
|
|
24
|
+
): Map<string, Set<string>> => {
|
|
25
|
+
const map = new Map<string, Set<string>>();
|
|
26
|
+
Object.entries(data).forEach(([folderId, ids]) => {
|
|
27
|
+
map.set(folderId, new Set(ids));
|
|
28
|
+
});
|
|
29
|
+
return map;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const loadFromStorage = (
|
|
33
|
+
storageKey: string,
|
|
34
|
+
): Map<string, Set<string>> | undefined => {
|
|
35
|
+
try {
|
|
36
|
+
const stored = sessionStorage.getItem(storageKey);
|
|
37
|
+
if (stored) {
|
|
38
|
+
const parsed = JSON.parse(stored) as SerializedSelection;
|
|
39
|
+
return deserializeSelection(parsed);
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore storage errors
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const saveToStorage = (
|
|
48
|
+
selection: Map<string, Set<string>>,
|
|
49
|
+
storageKey: string,
|
|
50
|
+
) => {
|
|
51
|
+
try {
|
|
52
|
+
const serialized = serializeSelection(selection);
|
|
53
|
+
sessionStorage.setItem(storageKey, JSON.stringify(serialized));
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore storage errors
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const buildSelectionMap = (
|
|
60
|
+
folderIds: string[],
|
|
61
|
+
initialSelection?: Map<string, Set<string>>,
|
|
62
|
+
) => {
|
|
63
|
+
const map = new Map<string, Set<string>>();
|
|
64
|
+
folderIds.forEach((folderId) => {
|
|
65
|
+
const initial = initialSelection?.get(folderId);
|
|
66
|
+
map.set(folderId, initial ? new Set(initial) : new Set<string>());
|
|
67
|
+
});
|
|
68
|
+
return map;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function createSelectionStore(
|
|
72
|
+
folderIds: string[],
|
|
73
|
+
storageKey: string = DEFAULT_STORAGE_KEY,
|
|
74
|
+
initialSelection?: Map<string, Set<string>>,
|
|
75
|
+
) {
|
|
76
|
+
const storedSelection = loadFromStorage(storageKey);
|
|
77
|
+
// Storage wins: use stored if exists, else fall back to initialSelection
|
|
78
|
+
const selectedByFolder = buildSelectionMap(
|
|
79
|
+
folderIds,
|
|
80
|
+
storedSelection ?? initialSelection,
|
|
81
|
+
);
|
|
82
|
+
const activeFolderId = folderIds[0] ?? "";
|
|
83
|
+
|
|
84
|
+
const store = create<SelectionState & SelectionAction>((set) => ({
|
|
85
|
+
selectedByFolder,
|
|
86
|
+
activeFolderId,
|
|
87
|
+
clear: (folderId?: string) =>
|
|
88
|
+
set((state) => {
|
|
89
|
+
if (folderId) {
|
|
90
|
+
const next = new Map(state.selectedByFolder);
|
|
91
|
+
next.set(folderId, new Set<string>());
|
|
92
|
+
return { selectedByFolder: next };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const next = new Map<string, Set<string>>();
|
|
96
|
+
state.selectedByFolder.forEach((_value, id) => {
|
|
97
|
+
next.set(id, new Set<string>());
|
|
98
|
+
});
|
|
99
|
+
return { selectedByFolder: next };
|
|
100
|
+
}),
|
|
101
|
+
setActiveFolder: (folderId: string) =>
|
|
102
|
+
set(() => ({ activeFolderId: folderId })),
|
|
103
|
+
setSelection: (folderId: string, ids: Set<string>) =>
|
|
104
|
+
set((state) => {
|
|
105
|
+
const next = new Map(state.selectedByFolder);
|
|
106
|
+
next.set(folderId, new Set(ids));
|
|
107
|
+
return { selectedByFolder: next };
|
|
108
|
+
}),
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
// Subscribe to changes and persist to storage
|
|
112
|
+
store.subscribe((state) => {
|
|
113
|
+
saveToStorage(state.selectedByFolder, storageKey);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return store;
|
|
117
|
+
}
|