@noya-app/noya-file-explorer 0.0.14 → 0.0.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-file-explorer",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -20,13 +20,13 @@
20
20
  "dev": "npm run build:main -- --watch"
21
21
  },
22
22
  "dependencies": {
23
- "@noya-app/noya-designsystem": "0.1.62",
24
- "@noya-app/noya-icons": "0.1.10",
25
- "@noya-app/noya-multiplayer-react": "0.1.61",
26
- "@noya-app/noya-keymap": "0.1.3",
27
- "@noya-app/react-utils": "0.1.22",
28
- "@noya-app/noya-utils": "0.1.5",
29
- "@noya-app/noya-schemas": "0.1.5",
23
+ "@noya-app/noya-designsystem": "0.1.64",
24
+ "@noya-app/noya-icons": "0.1.12",
25
+ "@noya-app/noya-multiplayer-react": "0.1.63",
26
+ "@noya-app/noya-keymap": "0.1.4",
27
+ "@noya-app/react-utils": "0.1.24",
28
+ "@noya-app/noya-utils": "0.1.6",
29
+ "@noya-app/noya-schemas": "0.1.6",
30
30
  "imfs": "^0.1.0",
31
31
  "browser-fs-access": "0.35.0"
32
32
  },
@@ -13,8 +13,11 @@ import {
13
13
  FileExplorerEmptyState,
14
14
  FileExplorerLayout,
15
15
  FileExplorerUploadButton,
16
+ formatByteSize,
16
17
  ListView,
17
18
  MediaThumbnail,
19
+ MediaThumbnailProps,
20
+ RelativeDropPosition,
18
21
  } from "@noya-app/noya-designsystem";
19
22
  import {
20
23
  DownloadIcon,
@@ -56,6 +59,7 @@ import {
56
59
  createMediaFile,
57
60
  createMediaFolder,
58
61
  createMediaItemTree,
62
+ MediaItemTree,
59
63
  PLACEHOLDER_ITEM_NAME,
60
64
  rootMediaItem,
61
65
  rootMediaItemName,
@@ -75,6 +79,7 @@ import {
75
79
  updateExpandedMap,
76
80
  validateMediaItemRename,
77
81
  } from "./utils/files";
82
+ import { handleDataTransfer } from "./utils/handleFileDrop";
78
83
 
79
84
  const extensionToContentType = {
80
85
  svg: "image/svg+xml",
@@ -118,8 +123,10 @@ const MediaThumbnailInternal = memoGeneric(
118
123
  selected,
119
124
  size,
120
125
  path: pathProp,
126
+ renderThumbnailIcon,
121
127
  }: CollectionThumbnailProps<MediaItem> & {
122
128
  path?: string;
129
+ renderThumbnailIcon?: MediaThumbnailProps["renderThumbnailIcon"];
123
130
  }) => {
124
131
  const asset = useAsset(item.kind === "asset" ? item.assetId : undefined);
125
132
  const isRoot = item.id === rootMediaItem.id;
@@ -151,6 +158,7 @@ const MediaThumbnailInternal = memoGeneric(
151
158
  selected={selected}
152
159
  size={size}
153
160
  fileName={fileName}
161
+ renderThumbnailIcon={renderThumbnailIcon}
154
162
  />
155
163
  );
156
164
  }
@@ -176,6 +184,7 @@ export type MediaCollectionRef = {
176
184
  moveUpAFolder: (selectedIds: string[]) => void;
177
185
  replace: (selectedItem: MediaItem) => void;
178
186
  preview: (selectedItems: MediaItem[]) => void;
187
+ getItemAtIndex: (index: number) => MediaItem | undefined;
179
188
  };
180
189
 
181
190
  type MediaCollectionProps = {
@@ -230,6 +239,8 @@ type MediaCollectionProps = {
230
239
  >;
231
240
  /** @default false */
232
241
  sortable?: boolean;
242
+ renderThumbnailIcon?: MediaThumbnailProps["renderThumbnailIcon"];
243
+ onDidDeleteItems?: (items: [string, MediaItem][]) => void;
233
244
  } & Pick<
234
245
  CollectionProps<MediaItem, MenuAction>,
235
246
  | "sortableId"
@@ -239,6 +250,7 @@ type MediaCollectionProps = {
239
250
  | "scrollable"
240
251
  | "renderEmptyState"
241
252
  | "sharedDragProps"
253
+ | "onClickItem"
242
254
  >;
243
255
 
244
256
  export const MediaCollection = memo(
@@ -269,6 +281,9 @@ export const MediaCollection = memo(
269
281
  sortable = false,
270
282
  renderEmptyState,
271
283
  sharedDragProps,
284
+ onClickItem,
285
+ renderThumbnailIcon,
286
+ onDidDeleteItems,
272
287
  },
273
288
  ref
274
289
  ) {
@@ -371,8 +386,7 @@ export const MediaCollection = memo(
371
386
  }
372
387
  }, [initialExpanded]);
373
388
 
374
- // Handle expansion state
375
- const handleExpanded = useCallback(
389
+ const getExpanded = useCallback(
376
390
  (item: MediaItem) => {
377
391
  if (!expandable) return undefined;
378
392
  if (item.kind !== "folder") return undefined;
@@ -384,15 +398,27 @@ export const MediaCollection = memo(
384
398
 
385
399
  const handleDelete = useCallback(
386
400
  (selectedIds: string[]) => {
401
+ const deletedItems = Object.entries(media).flatMap(
402
+ ([path, item]): [string, MediaItem][] => {
403
+ if (selectedIds.includes(item.id)) {
404
+ return [[path, item]];
405
+ }
406
+ return [];
407
+ }
408
+ );
409
+
387
410
  const newMedia = deleteMediaItems({
388
411
  selectedIds,
389
412
  media,
390
413
  tree,
391
414
  });
415
+
392
416
  setSelectedIds([rootMediaItem.id]);
393
417
  setMedia({ name: "Delete items", timestamp: Date.now() }, newMedia);
418
+
419
+ onDidDeleteItems?.(deletedItems);
394
420
  },
395
- [media, setMedia, setSelectedIds, tree]
421
+ [media, setMedia, setSelectedIds, tree, onDidDeleteItems]
396
422
  );
397
423
 
398
424
  const onRename = useCallback(
@@ -477,10 +503,13 @@ export const MediaCollection = memo(
477
503
  [media, tree, setMedia]
478
504
  );
479
505
 
506
+ const [isUploading, setIsUploading] = useState(false);
507
+
480
508
  const handleUpload = useCallback(
481
509
  async (selectedId: string) => {
482
510
  try {
483
511
  const files = await fileOpen({ multiple: true });
512
+
484
513
  if (!files || !Array.isArray(files) || files.length === 0) return;
485
514
 
486
515
  const parentPath = tree.idToPathMap.get(selectedId);
@@ -496,6 +525,8 @@ export const MediaCollection = memo(
496
525
  };
497
526
  });
498
527
 
528
+ setIsUploading(true);
529
+
499
530
  const newMediaMap = await Promise.all(uploadPromises);
500
531
 
501
532
  setMedia(
@@ -509,6 +540,8 @@ export const MediaCollection = memo(
509
540
  );
510
541
  } catch (error) {
511
542
  console.error("Failed to upload files:", error);
543
+ } finally {
544
+ setIsUploading(false);
512
545
  }
513
546
  },
514
547
  [tree.idToPathMap, setMedia, media, assetManager]
@@ -580,15 +613,13 @@ export const MediaCollection = memo(
580
613
 
581
614
  const handleMoveMediaInsideFolder = useCallback(
582
615
  (sourceItem: MediaItem, targetItem: MediaItem) => {
583
- const sourceItemPath = tree.idToPathMap.get(sourceItem.id);
584
- const targetItemPath = tree.idToPathMap.get(targetItem.id);
585
- if (!sourceItemPath || !targetItemPath) return;
586
616
  const newMedia = moveMediaInsideFolder({
587
617
  sourceItemIds: [sourceItem.id],
588
618
  targetItemId: targetItem.id,
589
619
  media,
590
620
  tree,
591
621
  });
622
+
592
623
  setMedia(
593
624
  {
594
625
  name: "Move media file inside folder",
@@ -622,12 +653,12 @@ export const MediaCollection = memo(
622
653
  ],
623
654
  [
624
655
  onlySingleFolderSelected && {
625
- title: "Add media",
656
+ title: "Upload Files",
626
657
  value: "upload",
627
658
  icon: <UploadIcon />,
628
659
  },
629
660
  onlySingleFolderSelected && {
630
- title: "Add a Folder",
661
+ title: "Add Folder",
631
662
  value: "addFolder",
632
663
  icon: <FolderIcon />,
633
664
  },
@@ -760,6 +791,7 @@ export const MediaCollection = memo(
760
791
  replace: handleReplace,
761
792
  preview: handlePreview,
762
793
  moveMediaInsideFolder: handleMoveMediaInsideFolder,
794
+ getItemAtIndex: (index) => visibleItems[index],
763
795
  }));
764
796
 
765
797
  return (
@@ -771,6 +803,7 @@ export const MediaCollection = memo(
771
803
  <FileExplorerUploadButton
772
804
  showUploadButton={showUploadButton}
773
805
  onUpload={() => handleUpload(rootMediaItem.id)}
806
+ isUploading={isUploading}
774
807
  >
775
808
  {right}
776
809
  </FileExplorerUploadButton>
@@ -801,10 +834,11 @@ export const MediaCollection = memo(
801
834
  return "Enter folder name";
802
835
  case "asset":
803
836
  case "file":
837
+ case "noyaFile":
804
838
  return "Enter file name";
805
839
  }
806
840
  }}
807
- getExpanded={handleExpanded}
841
+ getExpanded={getExpanded}
808
842
  setExpanded={handleSetExpanded}
809
843
  getRenamable={(item) => {
810
844
  if (item.id === rootMediaItem.id) return false;
@@ -814,6 +848,7 @@ export const MediaCollection = memo(
814
848
  menuItems={assetContextMenuItems}
815
849
  onSelectMenuItem={handleMenuAction}
816
850
  onSelectionChange={setSelectedIds}
851
+ onClickItem={onClickItem}
817
852
  onDoubleClickItem={onDoubleClickItem}
818
853
  onRename={onRename}
819
854
  renamable={renamable}
@@ -822,6 +857,7 @@ export const MediaCollection = memo(
822
857
  <MediaThumbnailInternal
823
858
  {...props}
824
859
  path={tree.idToPathMap.get(props.item.id)}
860
+ renderThumbnailIcon={renderThumbnailIcon}
825
861
  />
826
862
  )}
827
863
  renderAction={renderAction}
@@ -831,7 +867,7 @@ export const MediaCollection = memo(
831
867
  if (!asset) return null;
832
868
  return (
833
869
  <FileExplorerDetail selected={selected} size={size}>
834
- {(asset.size / 1024).toFixed(1)}KB
870
+ {formatByteSize(asset.size)}
835
871
  </FileExplorerDetail>
836
872
  );
837
873
  }}
@@ -839,7 +875,7 @@ export const MediaCollection = memo(
839
875
  renderEmptyState?.() ?? <FileExplorerEmptyState />
840
876
  }
841
877
  itemRoleDescription="clickable file item"
842
- getDropTargetParentIndex={(overIndex: number) => {
878
+ getDropTargetParentIndex={(overIndex) => {
843
879
  const item = visibleItems[overIndex];
844
880
  const parentIndex = visibleItems.findIndex(
845
881
  (i) => i.id === tree.getParentIdForId(item.id)
@@ -857,32 +893,31 @@ export const MediaCollection = memo(
857
893
  return false;
858
894
  }
859
895
 
860
- const sourceItem = visibleItems[sourceIndex];
861
- const targetItem = visibleItems[targetIndex];
862
-
863
- if (position !== "inside" || targetItem.kind === "asset") {
896
+ if (sourceListId !== sortableId || targetListId !== sortableId) {
864
897
  return false;
865
898
  }
866
- const sourcePath = tree.findPath(
867
- rootMediaItem,
868
- (item) => item.id === sourceItem.id
869
- );
870
- const targetPath = tree.findPath(
871
- rootMediaItem,
872
- (item) => item.id === targetItem.id
873
- );
874
899
 
875
- // Don't allow dragging into a descendant
876
- if (!sourcePath || !targetPath) return false;
877
- if (
878
- isDeepEqual(sourcePath, targetPath.slice(0, sourcePath.length))
879
- ) {
880
- return false;
881
- }
900
+ const sourceItem = visibleItems[sourceIndex];
901
+ const targetItem = visibleItems[targetIndex];
882
902
 
883
- return true;
903
+ return acceptsMediaItemDrop({
904
+ position,
905
+ sourceItem,
906
+ targetItem,
907
+ tree,
908
+ });
884
909
  }}
885
- onMoveItem={({ sourceIndex, targetIndex, position }) => {
910
+ onMoveItem={({
911
+ sourceListId,
912
+ sourceIndex,
913
+ targetListId,
914
+ targetIndex,
915
+ position,
916
+ }) => {
917
+ if (sourceListId !== sortableId || targetListId !== sortableId) {
918
+ return;
919
+ }
920
+
886
921
  const sourceItem = visibleItems[sourceIndex];
887
922
  const targetItem = visibleItems[targetIndex];
888
923
  if (position === "inside") {
@@ -892,40 +927,23 @@ export const MediaCollection = memo(
892
927
  onFilesDrop={async (event: React.DragEvent<Element>) => {
893
928
  event.preventDefault();
894
929
 
895
- const files = Array.from(event.dataTransfer.files);
896
- if (files.length === 0) return;
897
-
898
- try {
899
- // Upload all dropped files
900
- const uploadPromises = files.map(async (file) => {
901
- const asset = await assetManager.create(file);
902
- return {
903
- asset: createMediaAsset({
904
- assetId: asset.id,
905
- }),
906
- name: file.name,
907
- };
908
- });
909
-
910
- const newMediaItems = await Promise.all(uploadPromises);
911
- const rootItemPath = tree.idToPathMap.get(rootItemId);
912
- if (!rootItemPath) return;
913
- // Add all the media file references to our state
914
- setMedia(
915
- { name: "Add media files", timestamp: Date.now() },
916
- {
917
- ...media,
918
- ...Object.fromEntries(
919
- newMediaItems.map((item) => [
920
- path.join(rootItemPath, item.name),
921
- item.asset,
922
- ])
923
- ),
924
- }
925
- );
926
- } catch (error) {
927
- console.error("Failed to upload dropped files:", error);
928
- }
930
+ const rootItemPath = tree.idToPathMap.get(rootItemId);
931
+
932
+ if (!rootItemPath) return;
933
+
934
+ const newMedia = await handleDataTransfer({
935
+ dataTransfer: event.dataTransfer,
936
+ rootItemPath,
937
+ media,
938
+ uploadAsset: (file) => assetManager.create(file),
939
+ });
940
+
941
+ if (!newMedia) return;
942
+
943
+ setMedia(
944
+ { name: "Add media files", timestamp: Date.now() },
945
+ newMedia
946
+ );
929
947
  }}
930
948
  />
931
949
  </FileExplorerLayout>
@@ -933,3 +951,33 @@ export const MediaCollection = memo(
933
951
  );
934
952
  })
935
953
  );
954
+
955
+ export function acceptsMediaItemDrop(parameters: {
956
+ position: RelativeDropPosition;
957
+ sourceItem: MediaItem;
958
+ targetItem: MediaItem;
959
+ tree: MediaItemTree;
960
+ }) {
961
+ const { position, sourceItem, targetItem, tree } = parameters;
962
+
963
+ if (position !== "inside" || targetItem.kind === "asset") {
964
+ return false;
965
+ }
966
+ const sourcePath = tree.findPath(
967
+ rootMediaItem,
968
+ (item) => item.id === sourceItem.id
969
+ );
970
+ const targetPath = tree.findPath(
971
+ rootMediaItem,
972
+ (item) => item.id === targetItem.id
973
+ );
974
+
975
+ // Don't allow dragging into a descendant
976
+ if (!sourcePath || !targetPath) return false;
977
+
978
+ if (isDeepEqual(sourcePath, targetPath.slice(0, sourcePath.length))) {
979
+ return false;
980
+ }
981
+
982
+ return true;
983
+ }
@@ -38,6 +38,9 @@ export const getVisibleItems = ({
38
38
  }
39
39
  return;
40
40
  }
41
+ if (item.kind === "noyaFile" && fileKindFilter === "all") {
42
+ filteredItems.push(item);
43
+ }
41
44
  if (item.kind === "file" && fileKindFilter === "all") {
42
45
  filteredItems.push(item);
43
46
  }
@@ -0,0 +1,142 @@
1
+ import { Asset, MediaItem, MediaMap } from "@noya-app/noya-schemas";
2
+ import { path } from "imfs";
3
+ import { createMediaAsset, createMediaFolder } from "./mediaItemTree";
4
+
5
+ function isDirectoryEntry(
6
+ entry: FileSystemEntry
7
+ ): entry is FileSystemDirectoryEntry {
8
+ return entry.isDirectory;
9
+ }
10
+
11
+ function isFileEntry(entry: FileSystemEntry): entry is FileSystemFileEntry {
12
+ return entry.isFile;
13
+ }
14
+
15
+ export async function handleDataTransfer({
16
+ dataTransfer,
17
+ rootItemPath,
18
+ media,
19
+ uploadAsset,
20
+ }: {
21
+ dataTransfer: DataTransfer;
22
+ rootItemPath: string;
23
+ media: MediaMap;
24
+ uploadAsset: (file: File) => Promise<Asset>;
25
+ }): Promise<MediaMap | undefined> {
26
+ try {
27
+ const dataTransferItems = Array.from(dataTransfer.items ?? []);
28
+
29
+ const supportsEntries = dataTransferItems.some(
30
+ (item) => typeof item?.webkitGetAsEntry === "function"
31
+ );
32
+
33
+ type DroppedFile = { file: File; relativePath: string };
34
+
35
+ const collectFromEntry = async (
36
+ entry: FileSystemEntry,
37
+ basePath: string
38
+ ): Promise<DroppedFile[]> => {
39
+ if (!entry) return [];
40
+ if (isFileEntry(entry)) {
41
+ const file: File = await new Promise((resolve) =>
42
+ (entry as FileSystemFileEntry).file((f: File) => resolve(f))
43
+ );
44
+ return [
45
+ {
46
+ file,
47
+ relativePath: path.join(basePath, file.name),
48
+ },
49
+ ];
50
+ }
51
+ if (isDirectoryEntry(entry)) {
52
+ const reader = entry.createReader();
53
+ const readAll = async (): Promise<FileSystemEntry[]> => {
54
+ const all: FileSystemEntry[] = [];
55
+ // readEntries may return partial results; loop until empty
56
+ // eslint-disable-next-line no-constant-condition
57
+ while (true) {
58
+ const entries: FileSystemEntry[] = await new Promise((resolve) =>
59
+ reader.readEntries((ents: FileSystemEntry[]) => resolve(ents))
60
+ );
61
+ if (!entries.length) break;
62
+ all.push(...entries);
63
+ }
64
+ return all;
65
+ };
66
+ const children = await readAll();
67
+ const results = await Promise.all(
68
+ children.map((child) =>
69
+ collectFromEntry(child, path.join(basePath, entry.name))
70
+ )
71
+ );
72
+ return results.flat();
73
+ }
74
+ return [];
75
+ };
76
+
77
+ let dropped: DroppedFile[] = [];
78
+
79
+ if (supportsEntries) {
80
+ const topLevelEntries = dataTransferItems.flatMap((item) => {
81
+ const entry = item.webkitGetAsEntry?.();
82
+ if (!entry) return [];
83
+ return [entry];
84
+ });
85
+ const nested = await Promise.all(
86
+ topLevelEntries.map((entry) => collectFromEntry(entry, ""))
87
+ );
88
+ dropped = nested.flat();
89
+ } else {
90
+ const files = Array.from(dataTransfer.files);
91
+ if (files.length === 0) return;
92
+ dropped = files.map((file) => ({
93
+ file,
94
+ // Best effort: try webkitRelativePath; fall back to name
95
+ relativePath:
96
+ file.webkitRelativePath && file.webkitRelativePath.length > 0
97
+ ? file.webkitRelativePath
98
+ : file.name,
99
+ }));
100
+ }
101
+
102
+ if (dropped.length === 0) return;
103
+
104
+ const folderRelativePaths = new Set<string>();
105
+ for (const { relativePath } of dropped) {
106
+ const dir = path.dirname(relativePath);
107
+ if (dir && dir !== ".") {
108
+ const parts = dir.split("/").filter(Boolean);
109
+ let acc = "";
110
+ for (const part of parts) {
111
+ acc = acc ? path.join(acc, part) : part;
112
+ folderRelativePaths.add(acc);
113
+ }
114
+ }
115
+ }
116
+
117
+ const folderEntries: [string, MediaItem][] = Array.from(folderRelativePaths)
118
+ .map((rel) => path.join(rootItemPath, rel))
119
+ .filter((full) => !media[full])
120
+ .map((full) => [full, createMediaFolder() as MediaItem]);
121
+
122
+ const uploadResults = await Promise.all(
123
+ dropped.map(
124
+ async ({ file, relativePath }): Promise<[string, MediaItem]> => {
125
+ const asset = await uploadAsset(file);
126
+
127
+ return [
128
+ path.join(rootItemPath, relativePath),
129
+ createMediaAsset({ assetId: asset.id }) as MediaItem,
130
+ ];
131
+ }
132
+ )
133
+ );
134
+
135
+ return {
136
+ ...media,
137
+ ...Object.fromEntries([...folderEntries, ...uploadResults]),
138
+ };
139
+ } catch (error) {
140
+ console.error("Failed to upload dropped files:", error);
141
+ }
142
+ }