@noya-app/noya-file-explorer 0.0.15 → 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.15",
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.63",
24
- "@noya-app/noya-icons": "0.1.11",
25
- "@noya-app/noya-multiplayer-react": "0.1.62",
26
- "@noya-app/noya-keymap": "0.1.3",
27
- "@noya-app/react-utils": "0.1.23",
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,
@@ -63,7 +67,6 @@ import {
63
67
 
64
68
  import { path } from "imfs";
65
69
  import React from "react";
66
- import { formatByteSize } from "./formatByteSize";
67
70
  import {
68
71
  deleteMediaItems,
69
72
  ExpandedMap,
@@ -76,6 +79,7 @@ import {
76
79
  updateExpandedMap,
77
80
  validateMediaItemRename,
78
81
  } from "./utils/files";
82
+ import { handleDataTransfer } from "./utils/handleFileDrop";
79
83
 
80
84
  const extensionToContentType = {
81
85
  svg: "image/svg+xml",
@@ -119,8 +123,10 @@ const MediaThumbnailInternal = memoGeneric(
119
123
  selected,
120
124
  size,
121
125
  path: pathProp,
126
+ renderThumbnailIcon,
122
127
  }: CollectionThumbnailProps<MediaItem> & {
123
128
  path?: string;
129
+ renderThumbnailIcon?: MediaThumbnailProps["renderThumbnailIcon"];
124
130
  }) => {
125
131
  const asset = useAsset(item.kind === "asset" ? item.assetId : undefined);
126
132
  const isRoot = item.id === rootMediaItem.id;
@@ -152,6 +158,7 @@ const MediaThumbnailInternal = memoGeneric(
152
158
  selected={selected}
153
159
  size={size}
154
160
  fileName={fileName}
161
+ renderThumbnailIcon={renderThumbnailIcon}
155
162
  />
156
163
  );
157
164
  }
@@ -177,6 +184,7 @@ export type MediaCollectionRef = {
177
184
  moveUpAFolder: (selectedIds: string[]) => void;
178
185
  replace: (selectedItem: MediaItem) => void;
179
186
  preview: (selectedItems: MediaItem[]) => void;
187
+ getItemAtIndex: (index: number) => MediaItem | undefined;
180
188
  };
181
189
 
182
190
  type MediaCollectionProps = {
@@ -231,6 +239,8 @@ type MediaCollectionProps = {
231
239
  >;
232
240
  /** @default false */
233
241
  sortable?: boolean;
242
+ renderThumbnailIcon?: MediaThumbnailProps["renderThumbnailIcon"];
243
+ onDidDeleteItems?: (items: [string, MediaItem][]) => void;
234
244
  } & Pick<
235
245
  CollectionProps<MediaItem, MenuAction>,
236
246
  | "sortableId"
@@ -272,6 +282,8 @@ export const MediaCollection = memo(
272
282
  renderEmptyState,
273
283
  sharedDragProps,
274
284
  onClickItem,
285
+ renderThumbnailIcon,
286
+ onDidDeleteItems,
275
287
  },
276
288
  ref
277
289
  ) {
@@ -386,15 +398,27 @@ export const MediaCollection = memo(
386
398
 
387
399
  const handleDelete = useCallback(
388
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
+
389
410
  const newMedia = deleteMediaItems({
390
411
  selectedIds,
391
412
  media,
392
413
  tree,
393
414
  });
415
+
394
416
  setSelectedIds([rootMediaItem.id]);
395
417
  setMedia({ name: "Delete items", timestamp: Date.now() }, newMedia);
418
+
419
+ onDidDeleteItems?.(deletedItems);
396
420
  },
397
- [media, setMedia, setSelectedIds, tree]
421
+ [media, setMedia, setSelectedIds, tree, onDidDeleteItems]
398
422
  );
399
423
 
400
424
  const onRename = useCallback(
@@ -485,6 +509,7 @@ export const MediaCollection = memo(
485
509
  async (selectedId: string) => {
486
510
  try {
487
511
  const files = await fileOpen({ multiple: true });
512
+
488
513
  if (!files || !Array.isArray(files) || files.length === 0) return;
489
514
 
490
515
  const parentPath = tree.idToPathMap.get(selectedId);
@@ -588,15 +613,13 @@ export const MediaCollection = memo(
588
613
 
589
614
  const handleMoveMediaInsideFolder = useCallback(
590
615
  (sourceItem: MediaItem, targetItem: MediaItem) => {
591
- const sourceItemPath = tree.idToPathMap.get(sourceItem.id);
592
- const targetItemPath = tree.idToPathMap.get(targetItem.id);
593
- if (!sourceItemPath || !targetItemPath) return;
594
616
  const newMedia = moveMediaInsideFolder({
595
617
  sourceItemIds: [sourceItem.id],
596
618
  targetItemId: targetItem.id,
597
619
  media,
598
620
  tree,
599
621
  });
622
+
600
623
  setMedia(
601
624
  {
602
625
  name: "Move media file inside folder",
@@ -630,12 +653,12 @@ export const MediaCollection = memo(
630
653
  ],
631
654
  [
632
655
  onlySingleFolderSelected && {
633
- title: "Add media",
656
+ title: "Upload Files",
634
657
  value: "upload",
635
658
  icon: <UploadIcon />,
636
659
  },
637
660
  onlySingleFolderSelected && {
638
- title: "Add a Folder",
661
+ title: "Add Folder",
639
662
  value: "addFolder",
640
663
  icon: <FolderIcon />,
641
664
  },
@@ -768,6 +791,7 @@ export const MediaCollection = memo(
768
791
  replace: handleReplace,
769
792
  preview: handlePreview,
770
793
  moveMediaInsideFolder: handleMoveMediaInsideFolder,
794
+ getItemAtIndex: (index) => visibleItems[index],
771
795
  }));
772
796
 
773
797
  return (
@@ -810,6 +834,7 @@ export const MediaCollection = memo(
810
834
  return "Enter folder name";
811
835
  case "asset":
812
836
  case "file":
837
+ case "noyaFile":
813
838
  return "Enter file name";
814
839
  }
815
840
  }}
@@ -832,6 +857,7 @@ export const MediaCollection = memo(
832
857
  <MediaThumbnailInternal
833
858
  {...props}
834
859
  path={tree.idToPathMap.get(props.item.id)}
860
+ renderThumbnailIcon={renderThumbnailIcon}
835
861
  />
836
862
  )}
837
863
  renderAction={renderAction}
@@ -849,7 +875,7 @@ export const MediaCollection = memo(
849
875
  renderEmptyState?.() ?? <FileExplorerEmptyState />
850
876
  }
851
877
  itemRoleDescription="clickable file item"
852
- getDropTargetParentIndex={(overIndex: number) => {
878
+ getDropTargetParentIndex={(overIndex) => {
853
879
  const item = visibleItems[overIndex];
854
880
  const parentIndex = visibleItems.findIndex(
855
881
  (i) => i.id === tree.getParentIdForId(item.id)
@@ -867,32 +893,31 @@ export const MediaCollection = memo(
867
893
  return false;
868
894
  }
869
895
 
870
- const sourceItem = visibleItems[sourceIndex];
871
- const targetItem = visibleItems[targetIndex];
872
-
873
- if (position !== "inside" || targetItem.kind === "asset") {
896
+ if (sourceListId !== sortableId || targetListId !== sortableId) {
874
897
  return false;
875
898
  }
876
- const sourcePath = tree.findPath(
877
- rootMediaItem,
878
- (item) => item.id === sourceItem.id
879
- );
880
- const targetPath = tree.findPath(
881
- rootMediaItem,
882
- (item) => item.id === targetItem.id
883
- );
884
899
 
885
- // Don't allow dragging into a descendant
886
- if (!sourcePath || !targetPath) return false;
887
- if (
888
- isDeepEqual(sourcePath, targetPath.slice(0, sourcePath.length))
889
- ) {
890
- return false;
891
- }
900
+ const sourceItem = visibleItems[sourceIndex];
901
+ const targetItem = visibleItems[targetIndex];
892
902
 
893
- return true;
903
+ return acceptsMediaItemDrop({
904
+ position,
905
+ sourceItem,
906
+ targetItem,
907
+ tree,
908
+ });
894
909
  }}
895
- 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
+
896
921
  const sourceItem = visibleItems[sourceIndex];
897
922
  const targetItem = visibleItems[targetIndex];
898
923
  if (position === "inside") {
@@ -902,40 +927,23 @@ export const MediaCollection = memo(
902
927
  onFilesDrop={async (event: React.DragEvent<Element>) => {
903
928
  event.preventDefault();
904
929
 
905
- const files = Array.from(event.dataTransfer.files);
906
- if (files.length === 0) return;
907
-
908
- try {
909
- // Upload all dropped files
910
- const uploadPromises = files.map(async (file) => {
911
- const asset = await assetManager.create(file);
912
- return {
913
- asset: createMediaAsset({
914
- assetId: asset.id,
915
- }),
916
- name: file.name,
917
- };
918
- });
919
-
920
- const newMediaItems = await Promise.all(uploadPromises);
921
- const rootItemPath = tree.idToPathMap.get(rootItemId);
922
- if (!rootItemPath) return;
923
- // Add all the media file references to our state
924
- setMedia(
925
- { name: "Add media files", timestamp: Date.now() },
926
- {
927
- ...media,
928
- ...Object.fromEntries(
929
- newMediaItems.map((item) => [
930
- path.join(rootItemPath, item.name),
931
- item.asset,
932
- ])
933
- ),
934
- }
935
- );
936
- } catch (error) {
937
- console.error("Failed to upload dropped files:", error);
938
- }
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
+ );
939
947
  }}
940
948
  />
941
949
  </FileExplorerLayout>
@@ -943,3 +951,33 @@ export const MediaCollection = memo(
943
951
  );
944
952
  })
945
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
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import "./index.css";
2
- export * from "./formatByteSize";
3
2
  export * from "./MediaCollection";
4
3
  export * from "./utils/files";
5
4
  export * from "./utils/mediaItemTree";
@@ -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
+ }
@@ -1,8 +0,0 @@
1
- const byteSizeUnits = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
2
-
3
- export function formatByteSize(size: number) {
4
- const unitIndex = Math.floor(Math.log(size) / Math.log(1024));
5
- const unit = byteSizeUnits[unitIndex];
6
- const value = size / Math.pow(1024, unitIndex);
7
- return `${value.toFixed(1)} ${unit}`;
8
- }