@noya-app/noya-file-explorer 0.0.15 → 0.0.17

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.17",
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.65",
24
+ "@noya-app/noya-icons": "0.1.13",
25
+ "@noya-app/noya-multiplayer-react": "0.1.64",
26
+ "@noya-app/noya-keymap": "0.1.4",
27
+ "@noya-app/react-utils": "0.1.25",
28
+ "@noya-app/noya-utils": "0.1.7",
29
+ "@noya-app/noya-schemas": "0.1.7",
30
30
  "imfs": "^0.1.0",
31
31
  "browser-fs-access": "0.35.0"
32
32
  },
@@ -13,8 +13,12 @@ import {
13
13
  FileExplorerEmptyState,
14
14
  FileExplorerLayout,
15
15
  FileExplorerUploadButton,
16
+ formatByteSize,
16
17
  ListView,
17
18
  MediaThumbnail,
19
+ MediaThumbnailProps,
20
+ RelativeDropPosition,
21
+ useOpenConfirmationDialog,
18
22
  } from "@noya-app/noya-designsystem";
19
23
  import {
20
24
  DownloadIcon,
@@ -32,7 +36,12 @@ import {
32
36
  useAssetManager,
33
37
  useAssets,
34
38
  } from "@noya-app/noya-multiplayer-react";
35
- import { FileMediaItem, MediaItem, MediaMap } from "@noya-app/noya-schemas";
39
+ import {
40
+ AssetMediaItem,
41
+ FileMediaItem,
42
+ MediaItem,
43
+ MediaMap,
44
+ } from "@noya-app/noya-schemas";
36
45
  import { groupBy, isDeepEqual } from "@noya-app/noya-utils";
37
46
  import {
38
47
  downloadUrl,
@@ -56,6 +65,7 @@ import {
56
65
  createMediaFile,
57
66
  createMediaFolder,
58
67
  createMediaItemTree,
68
+ MediaItemTree,
59
69
  PLACEHOLDER_ITEM_NAME,
60
70
  rootMediaItem,
61
71
  rootMediaItemName,
@@ -63,18 +73,17 @@ import {
63
73
 
64
74
  import { path } from "imfs";
65
75
  import React from "react";
66
- import { formatByteSize } from "./formatByteSize";
67
76
  import {
68
- deleteMediaItems,
69
- ExpandedMap,
70
- FileKindFilter,
71
- getDepthMap,
72
- getVisibleItems,
73
- moveMediaInsideFolder,
74
- moveUpAFolder,
75
- renameMediaItemAndDescendantPaths,
76
- updateExpandedMap,
77
- validateMediaItemRename,
77
+ mediaDeleteMediaItems,
78
+ MediaExpandedMap,
79
+ MediaFileKindFilter,
80
+ mediaGetDepthMap,
81
+ mediaGetVisibleItems,
82
+ mediaMoveMediaInsideFolder,
83
+ mediaMoveUpAFolder,
84
+ mediaRenameMediaItemAndDescendantPaths,
85
+ mediaUpdateExpandedMap,
86
+ mediaValidateMediaItemRename,
78
87
  } from "./utils/files";
79
88
 
80
89
  const extensionToContentType = {
@@ -119,8 +128,10 @@ const MediaThumbnailInternal = memoGeneric(
119
128
  selected,
120
129
  size,
121
130
  path: pathProp,
131
+ renderThumbnailIcon,
122
132
  }: CollectionThumbnailProps<MediaItem> & {
123
133
  path?: string;
134
+ renderThumbnailIcon?: MediaThumbnailProps["renderThumbnailIcon"];
124
135
  }) => {
125
136
  const asset = useAsset(item.kind === "asset" ? item.assetId : undefined);
126
137
  const isRoot = item.id === rootMediaItem.id;
@@ -129,10 +140,14 @@ const MediaThumbnailInternal = memoGeneric(
129
140
 
130
141
  let contentType: string | undefined;
131
142
  let url: string | undefined;
143
+ let width: number | undefined;
144
+ let height: number | undefined;
132
145
 
133
146
  if (asset) {
134
147
  contentType = asset.contentType;
135
148
  url = asset.url;
149
+ width = asset.width ?? undefined;
150
+ height = asset.height ?? undefined;
136
151
  } else if (isFile && pathProp) {
137
152
  const encoded = encodeFileContentForThumbnail(pathProp, item);
138
153
 
@@ -143,6 +158,7 @@ const MediaThumbnailInternal = memoGeneric(
143
158
  }
144
159
 
145
160
  const fileName = pathProp ? path.basename(pathProp) : undefined;
161
+ const dimensions = width && height ? { width, height } : undefined;
146
162
 
147
163
  return (
148
164
  <MediaThumbnail
@@ -152,6 +168,8 @@ const MediaThumbnailInternal = memoGeneric(
152
168
  selected={selected}
153
169
  size={size}
154
170
  fileName={fileName}
171
+ renderThumbnailIcon={renderThumbnailIcon}
172
+ dimensions={dimensions}
155
173
  />
156
174
  );
157
175
  }
@@ -177,6 +195,7 @@ export type MediaCollectionRef = {
177
195
  moveUpAFolder: (selectedIds: string[]) => void;
178
196
  replace: (selectedItem: MediaItem) => void;
179
197
  preview: (selectedItems: MediaItem[]) => void;
198
+ getItemAtIndex: (index: number) => MediaItem | undefined;
180
199
  };
181
200
 
182
201
  type MediaCollectionProps = {
@@ -199,7 +218,7 @@ type MediaCollectionProps = {
199
218
  *
200
219
  * @default "all"
201
220
  * */
202
- fileKindFilter?: FileKindFilter;
221
+ fileKindFilter?: MediaFileKindFilter;
203
222
  /**
204
223
  * Whether to show the root item
205
224
  *
@@ -207,7 +226,7 @@ type MediaCollectionProps = {
207
226
  * */
208
227
  showRootItem?: boolean;
209
228
  /** Whether to expand all directories by default */
210
- initialExpanded?: ExpandedMap;
229
+ initialExpanded?: MediaExpandedMap;
211
230
  /**
212
231
  * Callback for when an item is double-clicked
213
232
  */
@@ -231,6 +250,9 @@ type MediaCollectionProps = {
231
250
  >;
232
251
  /** @default false */
233
252
  sortable?: boolean;
253
+ renderThumbnailIcon?: MediaThumbnailProps["renderThumbnailIcon"];
254
+ onDidDeleteItems?: (items: [string, MediaItem][]) => void;
255
+ onAssetsUploaded?: (mediaMap: Record<string, AssetMediaItem>) => void;
234
256
  } & Pick<
235
257
  CollectionProps<MediaItem, MenuAction>,
236
258
  | "sortableId"
@@ -272,6 +294,9 @@ export const MediaCollection = memo(
272
294
  renderEmptyState,
273
295
  sharedDragProps,
274
296
  onClickItem,
297
+ renderThumbnailIcon,
298
+ onDidDeleteItems,
299
+ onAssetsUploaded,
275
300
  },
276
301
  ref
277
302
  ) {
@@ -312,10 +337,10 @@ export const MediaCollection = memo(
312
337
  );
313
338
  const assetManager = useAssetManager();
314
339
  const assets = useAssets();
315
- const [expandedMap, setExpandedMap] = useState<ExpandedMap>({});
340
+ const [expandedMap, setExpandedMap] = useState<MediaExpandedMap>({});
316
341
  const visibleItems = useMemo(
317
342
  () =>
318
- getVisibleItems({
343
+ mediaGetVisibleItems({
319
344
  expandedMap,
320
345
  fileKindFilter: fileKindFilter,
321
346
  rootItemId,
@@ -333,7 +358,8 @@ export const MediaCollection = memo(
333
358
  ]
334
359
  );
335
360
  const depthMap = useMemo(
336
- () => getDepthMap(rootMediaItem, treeWithTempItem, showAllDescendants),
361
+ () =>
362
+ mediaGetDepthMap(rootMediaItem, treeWithTempItem, showAllDescendants),
337
363
  [treeWithTempItem, showAllDescendants]
338
364
  );
339
365
  const collectionRef = useRef<CollectionRef>(null);
@@ -384,17 +410,46 @@ export const MediaCollection = memo(
384
410
  [expandedMap, expandable]
385
411
  );
386
412
 
413
+ const openConfirmationDialog = useOpenConfirmationDialog();
414
+
387
415
  const handleDelete = useCallback(
388
- (selectedIds: string[]) => {
389
- const newMedia = deleteMediaItems({
416
+ async (selectedIds: string[]) => {
417
+ const ok = await openConfirmationDialog({
418
+ title: "Delete items",
419
+ description:
420
+ "Are you sure you want to delete these items? This action cannot be undone.",
421
+ });
422
+
423
+ if (!ok) return;
424
+
425
+ const deletedItems = Object.entries(media).flatMap(
426
+ ([path, item]): [string, MediaItem][] => {
427
+ if (selectedIds.includes(item.id)) {
428
+ return [[path, item]];
429
+ }
430
+ return [];
431
+ }
432
+ );
433
+
434
+ const newMedia = mediaDeleteMediaItems({
390
435
  selectedIds,
391
436
  media,
392
437
  tree,
393
438
  });
439
+
394
440
  setSelectedIds([rootMediaItem.id]);
395
441
  setMedia({ name: "Delete items", timestamp: Date.now() }, newMedia);
442
+
443
+ onDidDeleteItems?.(deletedItems);
396
444
  },
397
- [media, setMedia, setSelectedIds, tree]
445
+ [
446
+ media,
447
+ setMedia,
448
+ setSelectedIds,
449
+ tree,
450
+ onDidDeleteItems,
451
+ openConfirmationDialog,
452
+ ]
398
453
  );
399
454
 
400
455
  const onRename = useCallback(
@@ -404,7 +459,7 @@ export const MediaCollection = memo(
404
459
  selectedItem.id
405
460
  );
406
461
  if (!selectedItemPath) return;
407
- const renameIsValid = validateMediaItemRename({
462
+ const renameIsValid = mediaValidateMediaItemRename({
408
463
  basename: newName,
409
464
  selectedItemPath,
410
465
  media: temp.media,
@@ -414,7 +469,7 @@ export const MediaCollection = memo(
414
469
  return;
415
470
  }
416
471
  const mediaWithRenamedDescendantPaths =
417
- renameMediaItemAndDescendantPaths({
472
+ mediaRenameMediaItemAndDescendantPaths({
418
473
  newName,
419
474
  selectedItemPath,
420
475
  media: temp.media,
@@ -468,7 +523,7 @@ export const MediaCollection = memo(
468
523
 
469
524
  const handleMoveUpAFolder = useCallback(
470
525
  (selectedIds: string[]) => {
471
- const newMedia = moveUpAFolder({
526
+ const newMedia = mediaMoveUpAFolder({
472
527
  tree,
473
528
  media,
474
529
  selectedIds,
@@ -485,6 +540,7 @@ export const MediaCollection = memo(
485
540
  async (selectedId: string) => {
486
541
  try {
487
542
  const files = await fileOpen({ multiple: true });
543
+
488
544
  if (!files || !Array.isArray(files) || files.length === 0) return;
489
545
 
490
546
  const parentPath = tree.idToPathMap.get(selectedId);
@@ -502,24 +558,31 @@ export const MediaCollection = memo(
502
558
 
503
559
  setIsUploading(true);
504
560
 
505
- const newMediaMap = await Promise.all(uploadPromises);
561
+ const uploadedAssets = await Promise.all(uploadPromises);
562
+ const newMediaMap = Object.fromEntries(
563
+ uploadedAssets.map(({ assetPath, asset }) => [assetPath, asset])
564
+ );
506
565
 
507
566
  setMedia(
508
567
  { name: "Add media items", timestamp: Date.now() },
509
568
  {
510
569
  ...media,
511
- ...Object.fromEntries(
512
- newMediaMap.map(({ assetPath, asset }) => [assetPath, asset])
513
- ),
570
+ ...newMediaMap,
514
571
  }
515
572
  );
573
+
574
+ onAssetsUploaded?.(newMediaMap);
516
575
  } catch (error) {
517
- console.error("Failed to upload files:", error);
576
+ if (error instanceof Error && error.name === "AbortError") {
577
+ // Ignore user-cancelled file picker
578
+ } else {
579
+ console.error("Failed to upload files:", error);
580
+ }
518
581
  } finally {
519
582
  setIsUploading(false);
520
583
  }
521
584
  },
522
- [tree.idToPathMap, setMedia, media, assetManager]
585
+ [tree.idToPathMap, setMedia, media, assetManager, onAssetsUploaded]
523
586
  );
524
587
 
525
588
  const handleDownload = useCallback(
@@ -573,7 +636,11 @@ export const MediaCollection = memo(
573
636
  }
574
637
  );
575
638
  } catch (error) {
576
- console.error("Failed to upload file:", error);
639
+ if (error instanceof Error && error.name === "AbortError") {
640
+ // Ignore user-cancelled file picker
641
+ } else {
642
+ console.error("Failed to upload files:", error);
643
+ }
577
644
  }
578
645
  },
579
646
  [media, setMedia, assetManager, tree]
@@ -588,15 +655,13 @@ export const MediaCollection = memo(
588
655
 
589
656
  const handleMoveMediaInsideFolder = useCallback(
590
657
  (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
- const newMedia = moveMediaInsideFolder({
658
+ const newMedia = mediaMoveMediaInsideFolder({
595
659
  sourceItemIds: [sourceItem.id],
596
660
  targetItemId: targetItem.id,
597
661
  media,
598
662
  tree,
599
663
  });
664
+
600
665
  setMedia(
601
666
  {
602
667
  name: "Move media file inside folder",
@@ -630,12 +695,12 @@ export const MediaCollection = memo(
630
695
  ],
631
696
  [
632
697
  onlySingleFolderSelected && {
633
- title: "Add media",
698
+ title: "Upload Files",
634
699
  value: "upload",
635
700
  icon: <UploadIcon />,
636
701
  },
637
702
  onlySingleFolderSelected && {
638
- title: "Add a Folder",
703
+ title: "Add Folder",
639
704
  value: "addFolder",
640
705
  icon: <FolderIcon />,
641
706
  },
@@ -717,7 +782,7 @@ export const MediaCollection = memo(
717
782
  const handleSetExpanded = useCallback(
718
783
  (item: MediaItem, expanded: boolean) => {
719
784
  setExpandedMap((prev) =>
720
- updateExpandedMap({
785
+ mediaUpdateExpandedMap({
721
786
  item,
722
787
  expanded,
723
788
  expandable,
@@ -768,6 +833,7 @@ export const MediaCollection = memo(
768
833
  replace: handleReplace,
769
834
  preview: handlePreview,
770
835
  moveMediaInsideFolder: handleMoveMediaInsideFolder,
836
+ getItemAtIndex: (index) => visibleItems[index],
771
837
  }));
772
838
 
773
839
  return (
@@ -810,6 +876,7 @@ export const MediaCollection = memo(
810
876
  return "Enter folder name";
811
877
  case "asset":
812
878
  case "file":
879
+ case "noyaFile":
813
880
  return "Enter file name";
814
881
  }
815
882
  }}
@@ -832,6 +899,7 @@ export const MediaCollection = memo(
832
899
  <MediaThumbnailInternal
833
900
  {...props}
834
901
  path={tree.idToPathMap.get(props.item.id)}
902
+ renderThumbnailIcon={renderThumbnailIcon}
835
903
  />
836
904
  )}
837
905
  renderAction={renderAction}
@@ -849,7 +917,7 @@ export const MediaCollection = memo(
849
917
  renderEmptyState?.() ?? <FileExplorerEmptyState />
850
918
  }
851
919
  itemRoleDescription="clickable file item"
852
- getDropTargetParentIndex={(overIndex: number) => {
920
+ getDropTargetParentIndex={(overIndex) => {
853
921
  const item = visibleItems[overIndex];
854
922
  const parentIndex = visibleItems.findIndex(
855
923
  (i) => i.id === tree.getParentIdForId(item.id)
@@ -867,79 +935,70 @@ export const MediaCollection = memo(
867
935
  return false;
868
936
  }
869
937
 
870
- const sourceItem = visibleItems[sourceIndex];
871
- const targetItem = visibleItems[targetIndex];
872
-
873
- if (position !== "inside" || targetItem.kind === "asset") {
938
+ if (sourceListId !== sortableId || targetListId !== sortableId) {
874
939
  return false;
875
940
  }
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
941
 
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
- }
942
+ const sourceItem = visibleItems[sourceIndex];
943
+ const targetItem = visibleItems[targetIndex];
892
944
 
893
- return true;
945
+ return acceptsMediaItemDrop({
946
+ position,
947
+ sourceItem,
948
+ targetItem,
949
+ tree,
950
+ });
894
951
  }}
895
- onMoveItem={({ sourceIndex, targetIndex, position }) => {
952
+ onMoveItem={({
953
+ sourceListId,
954
+ sourceIndex,
955
+ targetListId,
956
+ targetIndex,
957
+ position,
958
+ }) => {
959
+ if (sourceListId !== sortableId || targetListId !== sortableId) {
960
+ return;
961
+ }
962
+
896
963
  const sourceItem = visibleItems[sourceIndex];
897
964
  const targetItem = visibleItems[targetIndex];
898
965
  if (position === "inside") {
899
966
  handleMoveMediaInsideFolder(sourceItem, targetItem);
900
967
  }
901
968
  }}
902
- onFilesDrop={async (event: React.DragEvent<Element>) => {
903
- event.preventDefault();
904
-
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
- }
939
- }}
940
969
  />
941
970
  </FileExplorerLayout>
942
971
  </>
943
972
  );
944
973
  })
945
974
  );
975
+
976
+ export function acceptsMediaItemDrop(parameters: {
977
+ position: RelativeDropPosition;
978
+ sourceItem: MediaItem;
979
+ targetItem: MediaItem;
980
+ tree: MediaItemTree;
981
+ }) {
982
+ const { position, sourceItem, targetItem, tree } = parameters;
983
+
984
+ if (position !== "inside" || targetItem.kind === "asset") {
985
+ return false;
986
+ }
987
+ const sourcePath = tree.findPath(
988
+ rootMediaItem,
989
+ (item) => item.id === sourceItem.id
990
+ );
991
+ const targetPath = tree.findPath(
992
+ rootMediaItem,
993
+ (item) => item.id === targetItem.id
994
+ );
995
+
996
+ // Don't allow dragging into a descendant
997
+ if (!sourcePath || !targetPath) return false;
998
+
999
+ if (isDeepEqual(sourcePath, targetPath.slice(0, sourcePath.length))) {
1000
+ return false;
1001
+ }
1002
+
1003
+ return true;
1004
+ }