mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 13:23:28 +00:00
[ENG-300] Explorer multi-select (#1197)
* grid * Improved multi-select, grid list & view offset. Added gap option & app frame. * List view multi-select * Include multi-select in overview, fix page ref type * Add gap to options panel * Fix drag * Update categories z-index * going pretty well * fix a couple bugs * fix another bug :) * minor improvements * Separate grid activeItem * extra comments * um akshully don't ref during render * show thumbnails yay * cleanup * Clean up * Fix ranges * here it is * fix cols drag * don't enforce selecto context * explorer view selectable * Update index.tsx * Context menu support for multi-select (#1187) * here it is * stopPropagation * cut copy multiple --------- Co-authored-by: nikec <nikec.job@gmail.com> * explorer view selectable * Update index.tsx * items Map * fix renamable * Update inspector * Hide tag assign if empty * fix merge * cleanup * fix un-rendered drag select * fix double click quick preview * update thumbnail * mostly handle multiple select in keybindings * fix ts * remove another todo * move useItemAs hooks to @sd/client * fix thumb controls * multi-select double click * cleaner? * smaller gap --------- Co-authored-by: Jamie Pine <ijamespine@me.com> Co-authored-by: Brendan Allan <brendonovich@outlook.com> Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Co-authored-by: Utku <74243531+utkubakir@users.noreply.github.com> Co-authored-by: Ericson "Fogo" Soares <ericson.ds999@gmail.com>
This commit is contained in:
parent
3d51c60900
commit
9c0aec8167
|
@ -130,12 +130,12 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
})
|
||||
.procedure("updateAccessTime", {
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), id: i32| async move {
|
||||
.mutation(|(_, library), ids: Vec<i32>| async move {
|
||||
library
|
||||
.db
|
||||
.object()
|
||||
.update(
|
||||
object::id::equals(id),
|
||||
.update_many(
|
||||
vec![object::id::in_vec(ids)],
|
||||
vec![object::date_accessed::set(Some(Utc::now().into()))],
|
||||
)
|
||||
.exec()
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use rspc::{alpha::AlphaRouter, ErrorCode};
|
||||
use sd_prisma::prisma_sync;
|
||||
|
@ -36,6 +38,42 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
.await?)
|
||||
})
|
||||
})
|
||||
.procedure("getWithObjects", {
|
||||
R.with2(library()).query(
|
||||
|(_, library), object_ids: Vec<object::id::Type>| async move {
|
||||
let Library { db, .. } = library.as_ref();
|
||||
|
||||
let tags_with_objects = db
|
||||
.tag()
|
||||
.find_many(vec![tag::tag_objects::some(vec![
|
||||
tag_on_object::object_id::in_vec(object_ids.clone()),
|
||||
])])
|
||||
.select(tag::select!({
|
||||
id
|
||||
tag_objects(vec![tag_on_object::object_id::in_vec(object_ids.clone())]): select {
|
||||
object: select {
|
||||
id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
Ok(tags_with_objects
|
||||
.into_iter()
|
||||
.map(|tag| {
|
||||
(
|
||||
tag.id,
|
||||
tag.tag_objects
|
||||
.into_iter()
|
||||
.map(|rel| rel.object.id)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>())
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("get", {
|
||||
R.with2(library())
|
||||
.query(|(_, library), tag_id: i32| async move {
|
||||
|
@ -137,6 +175,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
}
|
||||
|
||||
invalidate_query!(library, "tags.getForObject");
|
||||
invalidate_query!(library, "tags.getWithObjects");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
|
|
|
@ -1,30 +1,11 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import { FilePath, Location, NodeState, Tag } from '@sd/client';
|
||||
|
||||
export type ExplorerParent =
|
||||
| {
|
||||
type: 'Location';
|
||||
location: Location;
|
||||
subPath?: FilePath;
|
||||
}
|
||||
| {
|
||||
type: 'Tag';
|
||||
tag: Tag;
|
||||
}
|
||||
| {
|
||||
type: 'Node';
|
||||
node: NodeState;
|
||||
};
|
||||
|
||||
interface ExplorerContext {
|
||||
parent?: ExplorerParent;
|
||||
}
|
||||
import { UseExplorer } from './useExplorer';
|
||||
|
||||
/**
|
||||
* Context that must wrap anything to do with the explorer.
|
||||
* This includes explorer views, the inspector, and top bar items.
|
||||
*/
|
||||
export const ExplorerContext = createContext<ExplorerContext | null>(null);
|
||||
export const ExplorerContext = createContext<UseExplorer | null>(null);
|
||||
|
||||
export const useExplorerContext = () => {
|
||||
const ctx = useContext(ExplorerContext);
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
type UseCondition<TProps extends object> = () => TProps | null;
|
||||
|
||||
export class ConditionalItem<TProps extends object> {
|
||||
// Named like a hook to please eslint
|
||||
useCondition: UseCondition<TProps>;
|
||||
// Capital 'C' to please eslint + make rendering after destructuring easier
|
||||
Component: React.FC<TProps>;
|
||||
|
||||
constructor(public args: { useCondition: UseCondition<TProps>; Component: React.FC<TProps> }) {
|
||||
this.useCondition = args.useCondition;
|
||||
this.Component = args.Component;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConditionalGroupProps {
|
||||
items: ConditionalItem<any>[];
|
||||
children?: (children: ReactNode) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an array of `ConditionalItem` and attempts to render them all,
|
||||
* returning `null` if all conditions are `null`.
|
||||
*
|
||||
* @param items An array of `ConditionalItem` to render.
|
||||
* @param children An optional render function that can be used to wrap the rendered items.
|
||||
*/
|
||||
export const Conditional = ({ items, children }: ConditionalGroupProps) => {
|
||||
const itemConditions = items.map((item) => item.useCondition());
|
||||
|
||||
if (itemConditions.every((c) => c === null)) return null;
|
||||
|
||||
const renderedItems = (
|
||||
<>
|
||||
{itemConditions.map((props, i) => {
|
||||
if (props === null) return null;
|
||||
const { Component } = items[i]!;
|
||||
return <Component key={i} {...props} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return <>{children ? children(renderedItems) : renderedItems}</>;
|
||||
};
|
|
@ -1,17 +1,25 @@
|
|||
import { Copy, Scissors } from 'phosphor-react';
|
||||
import { FilePath, useLibraryMutation } from '@sd/client';
|
||||
import { useLibraryMutation } from '@sd/client';
|
||||
import { ContextMenu, ModifierKeys } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { getExplorerStore } from '../../store';
|
||||
import { useExplorerSearchParams } from '../../util';
|
||||
import { ConditionalItem } from '../ConditionalItem';
|
||||
import { useContextMenuContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
locationId: number;
|
||||
filePath: FilePath;
|
||||
}
|
||||
export const CutCopyItems = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { parent } = useExplorerContext();
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
|
||||
export const CutCopyItems = ({ locationId, filePath }: Props) => {
|
||||
if (parent?.type !== 'Location' || !isNonEmpty(selectedFilePaths)) return null;
|
||||
|
||||
return { locationId: parent.location.id, selectedFilePaths };
|
||||
},
|
||||
Component: ({ locationId, selectedFilePaths }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
|
@ -26,9 +34,8 @@ export const CutCopyItems = ({ locationId, filePath }: Props) => {
|
|||
getExplorerStore().cutCopyState = {
|
||||
sourceParentPath: path ?? '/',
|
||||
sourceLocationId: locationId,
|
||||
sourcePathId: filePath.id,
|
||||
actionType: 'Cut',
|
||||
active: true
|
||||
sourcePathIds: selectedFilePaths.map((p) => p.id),
|
||||
type: 'Cut'
|
||||
};
|
||||
}}
|
||||
icon={Scissors}
|
||||
|
@ -41,9 +48,8 @@ export const CutCopyItems = ({ locationId, filePath }: Props) => {
|
|||
getExplorerStore().cutCopyState = {
|
||||
sourceParentPath: path ?? '/',
|
||||
sourceLocationId: locationId,
|
||||
sourcePathId: filePath.id,
|
||||
actionType: 'Copy',
|
||||
active: true
|
||||
sourcePathIds: selectedFilePaths.map((p) => p.id),
|
||||
type: 'Copy'
|
||||
};
|
||||
}}
|
||||
icon={Copy}
|
||||
|
@ -56,7 +62,7 @@ export const CutCopyItems = ({ locationId, filePath }: Props) => {
|
|||
try {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: locationId,
|
||||
sources_file_path_ids: [filePath.id],
|
||||
sources_file_path_ids: selectedFilePaths.map((p) => p.id),
|
||||
target_location_id: locationId,
|
||||
target_location_relative_directory_path: path ?? '/',
|
||||
target_file_name_suffix: ' copy'
|
||||
|
@ -71,4 +77,5 @@ export const CutCopyItems = ({ locationId, filePath }: Props) => {
|
|||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,27 +1,34 @@
|
|||
import { ClipboardText, Image, Package, Trash, TrashSimple } from 'phosphor-react';
|
||||
import { FilePath, libraryClient, useLibraryContext, useLibraryMutation } from '@sd/client';
|
||||
import { Image, Package, Trash, TrashSimple } from 'phosphor-react';
|
||||
import { libraryClient, useLibraryContext, useLibraryMutation } from '@sd/client';
|
||||
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { CopyAsPathBase } from '../../CopyAsPath';
|
||||
import DeleteDialog from '../../FilePath/DeleteDialog';
|
||||
import EraseDialog from '../../FilePath/EraseDialog';
|
||||
import { Conditional, ConditionalItem } from '../ConditionalItem';
|
||||
import { useContextMenuContext } from '../context';
|
||||
import OpenWith from './OpenWith';
|
||||
|
||||
export * from './CutCopyItems';
|
||||
|
||||
interface FilePathProps {
|
||||
filePath: FilePath;
|
||||
}
|
||||
export const Delete = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedFilePaths)) return null;
|
||||
|
||||
export const Delete = ({ filePath }: FilePathProps) => {
|
||||
const locationId = selectedFilePaths[0].location_id;
|
||||
if (locationId === null) return null;
|
||||
|
||||
return { selectedFilePaths, locationId };
|
||||
},
|
||||
Component: ({ selectedFilePaths, locationId }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
const locationId = filePath.location_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
{locationId != null && (
|
||||
<ContextMenu.Item
|
||||
icon={Trash}
|
||||
label="Delete"
|
||||
|
@ -29,42 +36,40 @@ export const Delete = ({ filePath }: FilePathProps) => {
|
|||
keybind={keybind([ModifierKeys.Control], ['Delete'])}
|
||||
onClick={() =>
|
||||
dialogManager.create((dp) => (
|
||||
<DeleteDialog {...dp} location_id={locationId} path_id={filePath.id} />
|
||||
<DeleteDialog
|
||||
{...dp}
|
||||
locationId={locationId}
|
||||
pathIds={selectedFilePaths.map((p) => p.id)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CopyAsPath = ({ pathOrId }: { pathOrId: number | string }) => {
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
label="Copy as path"
|
||||
icon={ClipboardText}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path =
|
||||
typeof pathOrId === 'string'
|
||||
? pathOrId
|
||||
: await libraryClient.query(['files.getPath', pathOrId]);
|
||||
|
||||
if (path == null) throw new Error('No file path available');
|
||||
|
||||
navigator.clipboard.writeText(path);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to copy file path: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export const Compress = (_: FilePathProps) => {
|
||||
export const CopyAsPath = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedFilePaths) || selectedFilePaths.length > 1) return null;
|
||||
|
||||
return { selectedFilePaths };
|
||||
},
|
||||
Component: ({ selectedFilePaths }) => (
|
||||
<CopyAsPathBase
|
||||
getPath={() => libraryClient.query(['files.getPath', selectedFilePaths[0].id])}
|
||||
/>
|
||||
)
|
||||
});
|
||||
|
||||
export const Compress = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedFilePaths)) return null;
|
||||
|
||||
return { selectedFilePaths };
|
||||
},
|
||||
Component: ({ selectedFilePaths: _ }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
return (
|
||||
|
@ -75,9 +80,17 @@ export const Compress = (_: FilePathProps) => {
|
|||
disabled
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const Crypto = (_: FilePathProps) => {
|
||||
export const Crypto = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedFilePaths)) return null;
|
||||
|
||||
return { selectedFilePaths };
|
||||
},
|
||||
Component: ({ selectedFilePaths: _ }) => {
|
||||
return (
|
||||
<>
|
||||
{/* <ContextMenu.Item
|
||||
|
@ -130,34 +143,45 @@ export const Crypto = (_: FilePathProps) => {
|
|||
/> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const SecureDelete = ({ filePath }: FilePathProps) => {
|
||||
const locationId = filePath.location_id;
|
||||
export const SecureDelete = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedFilePaths)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{locationId && (
|
||||
const locationId = selectedFilePaths[0].location_id;
|
||||
if (locationId === null) return null;
|
||||
|
||||
return { locationId, selectedFilePaths };
|
||||
},
|
||||
Component: ({ locationId, selectedFilePaths }) => (
|
||||
<ContextMenu.Item
|
||||
variant="danger"
|
||||
label="Secure delete"
|
||||
icon={TrashSimple}
|
||||
onClick={() =>
|
||||
dialogManager.create((dp) => (
|
||||
<EraseDialog {...dp} location_id={locationId} path_id={filePath.id} />
|
||||
<EraseDialog {...dp} locationId={locationId} filePaths={selectedFilePaths} />
|
||||
))
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
});
|
||||
|
||||
export const ParentFolderActions = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { parent } = useExplorerContext();
|
||||
|
||||
if (parent?.type !== 'Location') return null;
|
||||
|
||||
return { parent };
|
||||
},
|
||||
Component: ({ parent }) => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
|
||||
export const ParentFolderActions = ({
|
||||
filePath,
|
||||
locationId
|
||||
}: FilePathProps & { locationId: number }) => {
|
||||
const fullRescan = useLibraryMutation('locations.fullRescan');
|
||||
const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation');
|
||||
|
||||
|
@ -167,7 +191,7 @@ export const ParentFolderActions = ({
|
|||
onClick={async () => {
|
||||
try {
|
||||
await fullRescan.mutateAsync({
|
||||
location_id: locationId,
|
||||
location_id: parent.location.id,
|
||||
reidentify_objects: false
|
||||
});
|
||||
} catch (error) {
|
||||
|
@ -184,8 +208,8 @@ export const ParentFolderActions = ({
|
|||
onClick={async () => {
|
||||
try {
|
||||
await generateThumbnails.mutateAsync({
|
||||
id: locationId,
|
||||
path: filePath.materialized_path ?? '/'
|
||||
id: parent.location.id,
|
||||
path: selectedFilePaths[0]?.materialized_path ?? '/'
|
||||
});
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
|
@ -199,11 +223,21 @@ export const ParentFolderActions = ({
|
|||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const OpenOrDownload = ({ filePath }: { filePath: FilePath }) => {
|
||||
export const OpenOrDownload = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
const { openFilePaths } = usePlatform();
|
||||
|
||||
if (!openFilePaths || !isNonEmpty(selectedFilePaths)) return null;
|
||||
|
||||
return { openFilePaths, selectedFilePaths };
|
||||
},
|
||||
Component: ({ openFilePaths, selectedFilePaths }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
const { platform, openFilePaths: openFilePath } = usePlatform();
|
||||
const { platform } = usePlatform();
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
|
||||
const { library } = useLibraryContext();
|
||||
|
@ -212,18 +246,23 @@ export const OpenOrDownload = ({ filePath }: { filePath: FilePath }) => {
|
|||
else
|
||||
return (
|
||||
<>
|
||||
{openFilePath && (
|
||||
<ContextMenu.Item
|
||||
label="Open"
|
||||
keybind={keybind([ModifierKeys.Control], ['O'])}
|
||||
onClick={async () => {
|
||||
if (filePath.object_id)
|
||||
if (selectedFilePaths.length < 1) return;
|
||||
|
||||
updateAccessTime
|
||||
.mutateAsync(filePath.object_id)
|
||||
.mutateAsync(
|
||||
selectedFilePaths.map((p) => p.object_id!).filter(Boolean)
|
||||
)
|
||||
.catch(console.error);
|
||||
|
||||
try {
|
||||
await openFilePath(library.uuid, [filePath.id]);
|
||||
await openFilePaths(
|
||||
library.uuid,
|
||||
selectedFilePaths.map((p) => p.id)
|
||||
);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
|
@ -232,8 +271,8 @@ export const OpenOrDownload = ({ filePath }: { filePath: FilePath }) => {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<OpenWith filePath={filePath} />
|
||||
<Conditional items={[OpenWith]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,21 +1,26 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Suspense } from 'react';
|
||||
import { FilePath, useLibraryContext } from '@sd/client';
|
||||
import { useLibraryContext } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { Platform, usePlatform } from '~/util/Platform';
|
||||
import { ConditionalItem } from '../ConditionalItem';
|
||||
import { useContextMenuContext } from '../context';
|
||||
|
||||
export default (props: { filePath: FilePath }) => {
|
||||
export default new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
const { getFilePathOpenWithApps, openFilePathWith } = usePlatform();
|
||||
|
||||
if (!getFilePathOpenWithApps || !openFilePathWith) return null;
|
||||
if (props.filePath.is_dir) return null;
|
||||
if (selectedFilePaths.some((p) => p.is_dir)) return null;
|
||||
|
||||
return (
|
||||
return { getFilePathOpenWithApps, openFilePathWith };
|
||||
},
|
||||
Component: ({ getFilePathOpenWithApps, openFilePathWith }) => (
|
||||
<ContextMenu.SubMenu label="Open with">
|
||||
<Suspense>
|
||||
<Items
|
||||
filePath={props.filePath}
|
||||
actions={{
|
||||
getFilePathOpenWithApps,
|
||||
openFilePathWith
|
||||
|
@ -23,21 +28,23 @@ export default (props: { filePath: FilePath }) => {
|
|||
/>
|
||||
</Suspense>
|
||||
</ContextMenu.SubMenu>
|
||||
);
|
||||
};
|
||||
)
|
||||
});
|
||||
|
||||
const Items = ({
|
||||
filePath,
|
||||
actions
|
||||
}: {
|
||||
filePath: FilePath;
|
||||
actions: Required<Pick<Platform, 'getFilePathOpenWithApps' | 'openFilePathWith'>>;
|
||||
}) => {
|
||||
const { selectedFilePaths } = useContextMenuContext();
|
||||
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
const ids = selectedFilePaths.map((obj) => obj.id);
|
||||
|
||||
const items = useQuery<unknown>(
|
||||
['openWith', filePath.id],
|
||||
() => actions.getFilePathOpenWithApps(library.uuid, [filePath.id]),
|
||||
['openWith', ids],
|
||||
() => actions.getFilePathOpenWithApps(library.uuid, ids),
|
||||
{ suspense: true }
|
||||
);
|
||||
|
||||
|
@ -49,9 +56,10 @@ const Items = ({
|
|||
key={id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await actions.openFilePathWith(library.uuid, [
|
||||
[filePath.id, data.url]
|
||||
]);
|
||||
await actions.openFilePathWith(
|
||||
library.uuid,
|
||||
ids.map((id) => [id, data.url])
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlertDialog({
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
import { Plus } from 'phosphor-react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { ExtraFn, FilePathItems, ObjectItems, SharedItems } from '../../ContextMenu';
|
||||
|
||||
interface Props {
|
||||
data: Extract<ExplorerItem, { type: 'Path' }>;
|
||||
extra?: ExtraFn;
|
||||
}
|
||||
|
||||
export default ({ data, extra }: Props) => {
|
||||
const filePath = data.item;
|
||||
const { object } = filePath;
|
||||
|
||||
const { parent } = useExplorerContext();
|
||||
|
||||
// const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false;
|
||||
// const mountedKeys = useLibraryQuery(['keys.listMounted']);
|
||||
// const hasMountedKeys = mountedKeys.data?.length ?? 0 > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilePathItems.OpenOrDownload filePath={filePath} />
|
||||
|
||||
<SharedItems.OpenQuickView item={data} />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Details />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.RevealInNativeExplorer filePath={filePath} />
|
||||
|
||||
<SharedItems.Rename />
|
||||
|
||||
{extra?.({
|
||||
object: filePath.object ?? undefined,
|
||||
filePath: filePath
|
||||
})}
|
||||
|
||||
<SharedItems.Deselect />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Share />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
{object && <ObjectItems.AssignTag object={object} />}
|
||||
|
||||
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
|
||||
<FilePathItems.CopyAsPath pathOrId={filePath.id} />
|
||||
|
||||
<FilePathItems.Crypto filePath={filePath} />
|
||||
|
||||
<FilePathItems.Compress filePath={filePath} />
|
||||
|
||||
{object && <ObjectItems.ConvertObject filePath={filePath} object={object} />}
|
||||
|
||||
{parent?.type === 'Location' && (
|
||||
<FilePathItems.ParentFolderActions
|
||||
filePath={filePath}
|
||||
locationId={parent.location.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FilePathItems.SecureDelete filePath={filePath} />
|
||||
</ContextMenu.SubMenu>
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<FilePathItems.Delete filePath={filePath} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
import { ExplorerItem } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { ExtraFn, SharedItems } from '..';
|
||||
|
||||
interface Props {
|
||||
data: Extract<ExplorerItem, { type: 'Location' }>;
|
||||
extra?: ExtraFn;
|
||||
}
|
||||
|
||||
export default ({ data, extra }: Props) => {
|
||||
const location = data.item;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SharedItems.OpenQuickView item={data} />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Details />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.RevealInNativeExplorer locationId={location.id} />
|
||||
|
||||
{extra?.({ location })}
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Share />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,20 +1,33 @@
|
|||
import { ArrowBendUpRight, TagSimple } from 'phosphor-react';
|
||||
import { FilePath, ObjectKind, Object as ObjectType, useLibraryMutation } from '@sd/client';
|
||||
import { useMemo } from 'react';
|
||||
import { ObjectKind, useLibraryMutation } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import AssignTagMenuItems from './AssignTagMenuItems';
|
||||
import AssignTagMenuItems from '~/components/AssignTagMenuItems';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { ConditionalItem } from '../ConditionalItem';
|
||||
import { useContextMenuContext } from '../context';
|
||||
|
||||
export const RemoveFromRecents = ({ object }: { object: ObjectType }) => {
|
||||
export const RemoveFromRecents = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedObjects } = useContextMenuContext();
|
||||
|
||||
if (!isNonEmpty(selectedObjects)) return null;
|
||||
|
||||
return { selectedObjects };
|
||||
},
|
||||
|
||||
Component: ({ selectedObjects }) => {
|
||||
const removeFromRecents = useLibraryMutation('files.removeAccessTime');
|
||||
|
||||
return (
|
||||
<>
|
||||
{object.date_accessed !== null && (
|
||||
<ContextMenu.Item
|
||||
label="Remove from recents"
|
||||
label="Remove From Recents"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeFromRecents.mutateAsync([object.id]);
|
||||
await removeFromRecents.mutateAsync(
|
||||
selectedObjects.map((object) => object.id)
|
||||
);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
|
@ -23,34 +36,57 @@ export const RemoveFromRecents = ({ object }: { object: ObjectType }) => {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const AssignTag = ({ object }: { object: ObjectType }) => (
|
||||
export const AssignTag = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedObjects } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedObjects)) return null;
|
||||
|
||||
return { selectedObjects };
|
||||
},
|
||||
Component: ({ selectedObjects }) => (
|
||||
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
|
||||
<AssignTagMenuItems objectId={object.id} />
|
||||
<AssignTagMenuItems objects={selectedObjects} />
|
||||
</ContextMenu.SubMenu>
|
||||
);
|
||||
)
|
||||
});
|
||||
|
||||
const ObjectConversions: Record<number, string[]> = {
|
||||
[ObjectKind.Image]: ['PNG', 'WebP', 'Gif'],
|
||||
[ObjectKind.Video]: ['MP4', 'MOV', 'AVI']
|
||||
};
|
||||
|
||||
export const ConvertObject = ({ object, filePath }: { object: ObjectType; filePath: FilePath }) => {
|
||||
const { kind } = object;
|
||||
const ConvertableKinds = [ObjectKind.Image, ObjectKind.Video];
|
||||
|
||||
return (
|
||||
<>
|
||||
{kind !== null && [ObjectKind.Image, ObjectKind.Video].includes(kind as ObjectKind) && (
|
||||
export const ConvertObject = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedObjects } = useContextMenuContext();
|
||||
|
||||
const kinds = useMemo(() => {
|
||||
const set = new Set<ObjectKind>();
|
||||
|
||||
for (const o of selectedObjects) {
|
||||
if (o.kind === null || !ConvertableKinds.includes(o.kind)) break;
|
||||
set.add(o.kind);
|
||||
}
|
||||
|
||||
return [...set];
|
||||
}, [selectedObjects]);
|
||||
|
||||
if (!isNonEmpty(kinds) || kinds.length > 1) return null;
|
||||
|
||||
const [kind] = kinds;
|
||||
|
||||
return { kind };
|
||||
},
|
||||
Component: ({ kind }) => (
|
||||
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
|
||||
{ObjectConversions[kind]?.map((ext) => (
|
||||
<ContextMenu.Item key={ext} label={ext} disabled />
|
||||
))}
|
||||
</ContextMenu.SubMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
});
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
import { Plus } from 'phosphor-react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { ExtraFn, FilePathItems, ObjectItems, SharedItems } from '..';
|
||||
|
||||
interface Props {
|
||||
data: Extract<ExplorerItem, { type: 'Object' }>;
|
||||
extra?: ExtraFn;
|
||||
}
|
||||
|
||||
export default ({ data, extra }: Props) => {
|
||||
const object = data.item;
|
||||
const filePath = data.item.file_paths[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
{filePath && <FilePathItems.OpenOrDownload filePath={filePath} />}
|
||||
|
||||
<SharedItems.OpenQuickView item={data} />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Details />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
{filePath && <SharedItems.RevealInNativeExplorer filePath={filePath} />}
|
||||
|
||||
<SharedItems.Rename />
|
||||
|
||||
{extra?.({
|
||||
object: object,
|
||||
filePath: filePath
|
||||
})}
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<SharedItems.Share />
|
||||
|
||||
{(object || filePath) && <ContextMenu.Separator />}
|
||||
|
||||
{object && <ObjectItems.AssignTag object={object} />}
|
||||
|
||||
{filePath && (
|
||||
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
|
||||
<FilePathItems.CopyAsPath pathOrId={filePath.id} />
|
||||
<FilePathItems.Crypto filePath={filePath} />
|
||||
<FilePathItems.Compress filePath={filePath} />
|
||||
<ObjectItems.ConvertObject filePath={filePath} object={object} />
|
||||
</ContextMenu.SubMenu>
|
||||
)}
|
||||
|
||||
{filePath && (
|
||||
<>
|
||||
<ContextMenu.Separator />
|
||||
<FilePathItems.Delete filePath={filePath} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,121 +1,143 @@
|
|||
import { FileX, Share as ShareIcon } from 'phosphor-react';
|
||||
import { useMemo } from 'react';
|
||||
import { ExplorerItem, FilePath, useLibraryContext } from '@sd/client';
|
||||
import { ContextMenu, ModifierKeys } from '@sd/ui';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { Platform } from '~/util/Platform';
|
||||
import { RevealInNativeExplorerBase } from '../RevealInNativeExplorer';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
import { ConditionalItem } from './ConditionalItem';
|
||||
import { useContextMenuContext } from './context';
|
||||
|
||||
export const OpenQuickView = ({ item }: { item: ExplorerItem }) => {
|
||||
export const OpenQuickView = () => {
|
||||
const keybind = useKeybindFactory();
|
||||
const { selectedItems } = useContextMenuContext();
|
||||
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
label="Quick view"
|
||||
keybind={keybind([], [' '])}
|
||||
onClick={() => (getExplorerStore().quickViewObject = item)}
|
||||
onClick={() =>
|
||||
// using [0] is not great
|
||||
(getExplorerStore().quickViewObject = selectedItems[0])
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Details = () => {
|
||||
export const Details = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { showInspector } = useExplorerStore();
|
||||
if (showInspector) return null;
|
||||
|
||||
return {};
|
||||
},
|
||||
Component: () => {
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!showInspector && (
|
||||
<ContextMenu.Item
|
||||
label="Details"
|
||||
keybind={keybind([ModifierKeys.Control], ['I'])}
|
||||
// icon={Sidebar}
|
||||
onClick={() => (getExplorerStore().showInspector = true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const Rename = () => {
|
||||
export const Rename = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedItems } = useContextMenuContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
if (explorerStore.layoutMode === 'media' || selectedItems.length > 1) return null;
|
||||
|
||||
return {};
|
||||
},
|
||||
Component: () => {
|
||||
const explorerView = useExplorerViewContext();
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
return (
|
||||
<>
|
||||
{explorerStore.layoutMode !== 'media' && (
|
||||
<ContextMenu.Item
|
||||
label="Rename"
|
||||
keybind={keybind([], ['Enter'])}
|
||||
onClick={() => explorerView.setIsRenaming(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const RevealInNativeExplorer = (props: { locationId: number } | { filePath: FilePath }) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = useKeybindFactory();
|
||||
const { revealItems } = usePlatform();
|
||||
const { library } = useLibraryContext();
|
||||
export const RevealInNativeExplorer = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedItems } = useContextMenuContext();
|
||||
|
||||
const osFileBrowserName = useMemo(() => {
|
||||
const lookup: Record<string, string> = {
|
||||
macOS: 'Finder',
|
||||
windows: 'Explorer'
|
||||
};
|
||||
const items = useMemo(() => {
|
||||
const array: Parameters<NonNullable<Platform['revealItems']>>[1] = [];
|
||||
|
||||
return lookup[os] ?? 'file manager';
|
||||
}, [os]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{revealItems && (
|
||||
<ContextMenu.Item
|
||||
label={`Reveal in ${osFileBrowserName}`}
|
||||
keybind={keybind([ModifierKeys.Control], ['Y'])}
|
||||
onClick={() => (
|
||||
console.log(props),
|
||||
revealItems(library.uuid, [
|
||||
'filePath' in props
|
||||
? {
|
||||
for (const item of selectedItems) {
|
||||
switch (item.type) {
|
||||
case 'Path': {
|
||||
array.push({
|
||||
FilePath: { id: item.item.id }
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'Object': {
|
||||
// this isn't good but it's the current behaviour
|
||||
const filePath = item.item.file_paths[0];
|
||||
if (filePath)
|
||||
array.push({
|
||||
FilePath: {
|
||||
id: props.filePath.id
|
||||
id: filePath.id
|
||||
}
|
||||
});
|
||||
else return [];
|
||||
break;
|
||||
}
|
||||
: {
|
||||
case 'Location': {
|
||||
array.push({
|
||||
Location: {
|
||||
id: props.locationId
|
||||
id: item.item.id
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Deselect = () => {
|
||||
return array;
|
||||
}, [selectedItems]);
|
||||
|
||||
if (!isNonEmpty(items)) return null;
|
||||
|
||||
return { items };
|
||||
},
|
||||
Component: ({ items }) => <RevealInNativeExplorerBase items={items} />
|
||||
});
|
||||
|
||||
export const Deselect = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { cutCopyState } = useExplorerStore();
|
||||
|
||||
return (
|
||||
if (cutCopyState.type === 'Idle') return null;
|
||||
|
||||
return {};
|
||||
},
|
||||
Component: () => (
|
||||
<ContextMenu.Item
|
||||
label="Deselect"
|
||||
hidden={!cutCopyState.active}
|
||||
icon={FileX}
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
...cutCopyState,
|
||||
active: false
|
||||
type: 'Idle'
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
});
|
||||
|
||||
export const Share = () => {
|
||||
return (
|
||||
|
|
31
interface/app/$libraryId/Explorer/ContextMenu/context.tsx
Normal file
31
interface/app/$libraryId/Explorer/ContextMenu/context.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||
import { ExplorerItem, FilePath, Object, useItemsAsFilePaths, useItemsAsObjects } from '@sd/client';
|
||||
import { NonEmptyArray } from '~/util';
|
||||
|
||||
const ContextMenuContext = createContext<{
|
||||
selectedItems: NonEmptyArray<ExplorerItem>;
|
||||
selectedFilePaths: FilePath[];
|
||||
selectedObjects: Object[];
|
||||
} | null>(null);
|
||||
|
||||
export const ContextMenuContextProvider = ({
|
||||
selectedItems,
|
||||
children
|
||||
}: PropsWithChildren<{
|
||||
selectedItems: NonEmptyArray<ExplorerItem>;
|
||||
}>) => {
|
||||
const selectedFilePaths = useItemsAsFilePaths(selectedItems);
|
||||
const selectedObjects = useItemsAsObjects(selectedItems);
|
||||
|
||||
return (
|
||||
<ContextMenuContext.Provider value={{ selectedItems, selectedFilePaths, selectedObjects }}>
|
||||
{children}
|
||||
</ContextMenuContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useContextMenuContext = () => {
|
||||
const context = useContext(ContextMenuContext);
|
||||
if (!context) throw new Error('ContextMenuContext.Provider not found');
|
||||
return context;
|
||||
};
|
|
@ -1,26 +1,85 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { ExplorerItem, FilePath, Location, Object } from '@sd/client';
|
||||
import FilePathCM from './FilePath';
|
||||
import LocationCM from './Location';
|
||||
import ObjectCM from './Object';
|
||||
import { Plus } from 'phosphor-react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { Conditional, ConditionalGroupProps } from './ConditionalItem';
|
||||
import * as FilePathItems from './FilePath/Items';
|
||||
import * as ObjectItems from './Object/Items';
|
||||
import * as SharedItems from './SharedItems';
|
||||
import { ContextMenuContextProvider } from './context';
|
||||
|
||||
export * as SharedItems from './SharedItems';
|
||||
export * as FilePathItems from './FilePath/Items';
|
||||
export * as ObjectItems from './Object/Items';
|
||||
|
||||
export type ExtraFn = (a: {
|
||||
object?: Object;
|
||||
filePath?: FilePath;
|
||||
location?: Location;
|
||||
}) => ReactNode;
|
||||
const Items = ({ children }: { children?: () => ReactNode }) => (
|
||||
<>
|
||||
<Conditional items={[FilePathItems.OpenOrDownload]} />
|
||||
<SharedItems.OpenQuickView />
|
||||
|
||||
export default ({ item, extra }: { item: ExplorerItem; extra?: ExtraFn }) => {
|
||||
switch (item.type) {
|
||||
case 'Path':
|
||||
return <FilePathCM data={item} extra={extra} />;
|
||||
case 'Object':
|
||||
return <ObjectCM data={item} extra={extra} />;
|
||||
case 'Location':
|
||||
return <LocationCM data={item} extra={extra} />;
|
||||
}
|
||||
<SeparatedConditional items={[SharedItems.Details]} />
|
||||
|
||||
<ContextMenu.Separator />
|
||||
<Conditional
|
||||
items={[
|
||||
SharedItems.RevealInNativeExplorer,
|
||||
SharedItems.Rename,
|
||||
FilePathItems.CutCopyItems,
|
||||
SharedItems.Deselect
|
||||
]}
|
||||
/>
|
||||
{children?.()}
|
||||
|
||||
<ContextMenu.Separator />
|
||||
<SharedItems.Share />
|
||||
|
||||
<SeparatedConditional items={[ObjectItems.AssignTag]} />
|
||||
|
||||
<Conditional
|
||||
items={[
|
||||
FilePathItems.CopyAsPath,
|
||||
FilePathItems.Crypto,
|
||||
FilePathItems.Compress,
|
||||
ObjectItems.ConvertObject,
|
||||
FilePathItems.ParentFolderActions,
|
||||
FilePathItems.SecureDelete
|
||||
]}
|
||||
>
|
||||
{(items) => (
|
||||
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
|
||||
{items}
|
||||
</ContextMenu.SubMenu>
|
||||
)}
|
||||
</Conditional>
|
||||
|
||||
<SeparatedConditional items={[FilePathItems.Delete]} />
|
||||
</>
|
||||
);
|
||||
|
||||
export default ({ children }: { children?: () => ReactNode }) => {
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const selectedItems = useMemo(() => [...explorer.selectedItems], [explorer.selectedItems]);
|
||||
if (!isNonEmpty(selectedItems)) return null;
|
||||
|
||||
return (
|
||||
<ContextMenuContextProvider selectedItems={selectedItems}>
|
||||
<Items>{children}</Items>
|
||||
</ContextMenuContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A `Conditional` that inserts a `<ContextMenu.Separator />` above its items.
|
||||
*/
|
||||
const SeparatedConditional = ({ items, children }: ConditionalGroupProps) => (
|
||||
<Conditional items={items}>
|
||||
{(c) => (
|
||||
<>
|
||||
<ContextMenu.Separator />
|
||||
{children ? children(c) : c}
|
||||
</>
|
||||
)}
|
||||
</Conditional>
|
||||
);
|
||||
|
|
33
interface/app/$libraryId/Explorer/CopyAsPath.tsx
Normal file
33
interface/app/$libraryId/Explorer/CopyAsPath.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { ClipboardText } from 'phosphor-react';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
|
||||
export const CopyAsPathBase = (
|
||||
props: { path: string } | { getPath: () => Promise<string | null> }
|
||||
) => {
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
label="Copy as path"
|
||||
icon={ClipboardText}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = 'path' in props ? props.path : await props.getPath();
|
||||
{
|
||||
/* 'path' in props
|
||||
? props.path
|
||||
: await libraryClient.query(['files.getPath', props.filePath.id]); */
|
||||
}
|
||||
|
||||
if (path == null) throw new Error('No file path available');
|
||||
|
||||
navigator.clipboard.writeText(path);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to copy file path: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -3,8 +3,8 @@ import { CheckBox, Dialog, Tooltip, UseDialogProps, useDialog } from '@sd/ui';
|
|||
import { useZodForm } from '@sd/ui/src/forms';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
location_id: number;
|
||||
path_id: number;
|
||||
locationId: number;
|
||||
pathIds: number[];
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
|
@ -17,8 +17,8 @@ export default (props: Props) => {
|
|||
form={form}
|
||||
onSubmit={form.handleSubmit(() =>
|
||||
deleteFile.mutateAsync({
|
||||
location_id: props.location_id,
|
||||
file_path_ids: [props.path_id]
|
||||
location_id: props.locationId,
|
||||
file_path_ids: props.pathIds
|
||||
})
|
||||
)}
|
||||
dialog={useDialog(props)}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { useState } from 'react';
|
||||
import { useLibraryMutation } from '@sd/client';
|
||||
import { FilePath, useLibraryMutation } from '@sd/client';
|
||||
import { Dialog, Slider, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
location_id: number;
|
||||
path_id: number;
|
||||
locationId: number;
|
||||
filePaths: FilePath[];
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
|
@ -29,8 +29,8 @@ export default (props: Props) => {
|
|||
form={form}
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
eraseFile.mutateAsync({
|
||||
location_id: props.location_id,
|
||||
file_path_ids: [props.path_id],
|
||||
location_id: props.locationId,
|
||||
file_path_ids: props.filePaths.map((p) => p.id),
|
||||
passes: data.passes.toString()
|
||||
})
|
||||
)}
|
||||
|
|
|
@ -6,3 +6,12 @@
|
|||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
}
|
||||
|
||||
.checkersLight {
|
||||
background-image: linear-gradient(45deg, #e2e2e2 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e2e2e2 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e2e2e2 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e2e2e2 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
import { getIcon, iconNames } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { ImgHTMLAttributes, memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ImgHTMLAttributes,
|
||||
VideoHTMLAttributes,
|
||||
memo,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { ExplorerItem, getItemFilePath, getItemLocation, useLibraryContext } from '@sd/client';
|
||||
import { PDFViewer, TEXTViewer } from '~/components';
|
||||
import { useCallbackToWatchResize, useIsDark } from '~/hooks';
|
||||
|
@ -11,177 +19,101 @@ import { getExplorerStore } from '../store';
|
|||
import { useExplorerItemData } from '../util';
|
||||
import classes from './Thumb.module.scss';
|
||||
|
||||
interface ThumbnailProps {
|
||||
src: string;
|
||||
cover?: boolean;
|
||||
onLoad?: () => void;
|
||||
onError?: () => void;
|
||||
decoding?: ImgHTMLAttributes<HTMLImageElement>['decoding'];
|
||||
className?: string;
|
||||
crossOrigin?: ImgHTMLAttributes<HTMLImageElement>['crossOrigin'];
|
||||
videoBarsSize?: number;
|
||||
videoExtension?: string;
|
||||
}
|
||||
const THUMB_TYPE = {
|
||||
ICON: 'icon',
|
||||
ORIGINAL: 'original',
|
||||
THUMBNAIL: 'thumbnail'
|
||||
} as const;
|
||||
|
||||
const Thumbnail = memo(
|
||||
({ crossOrigin, videoBarsSize, videoExtension, ...props }: ThumbnailProps) => {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const [size, setSize] = useState<null | { width: number; height: number }>(null);
|
||||
|
||||
useCallbackToWatchResize(
|
||||
(rect) => {
|
||||
const { width, height } = rect;
|
||||
setSize((width && height && { width, height }) || null);
|
||||
},
|
||||
[],
|
||||
ref
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
// Order matter for crossOrigin attr
|
||||
// https://github.com/facebook/react/issues/14035#issuecomment-642227899
|
||||
{...(crossOrigin ? { crossOrigin } : {})}
|
||||
src={props.src}
|
||||
ref={ref}
|
||||
style={
|
||||
videoBarsSize
|
||||
? size && size.height >= size.width
|
||||
? {
|
||||
borderLeftWidth: videoBarsSize,
|
||||
borderRightWidth: videoBarsSize
|
||||
}
|
||||
: {
|
||||
borderTopWidth: videoBarsSize,
|
||||
borderBottomWidth: videoBarsSize
|
||||
}
|
||||
: {}
|
||||
}
|
||||
onLoad={props.onLoad}
|
||||
onError={() => {
|
||||
props.onError?.();
|
||||
setSize(null);
|
||||
}}
|
||||
decoding={props.decoding}
|
||||
className={props.className}
|
||||
draggable={false}
|
||||
/>
|
||||
{videoExtension && (
|
||||
<div
|
||||
style={
|
||||
props.cover
|
||||
? {}
|
||||
: size
|
||||
? {
|
||||
marginTop: Math.floor(size.height / 2) - 2,
|
||||
marginLeft: Math.floor(size.width / 2) - 2
|
||||
}
|
||||
: { display: 'none' }
|
||||
}
|
||||
className={clsx(
|
||||
props.cover
|
||||
? 'bottom-1 right-1'
|
||||
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full',
|
||||
'absolute rounded !text-white',
|
||||
'bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase opacity-70'
|
||||
)}
|
||||
>
|
||||
{videoExtension}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
enum ThumbType {
|
||||
Icon,
|
||||
Original,
|
||||
Thumbnail,
|
||||
Location
|
||||
}
|
||||
type ThumbType = (typeof THUMB_TYPE)[keyof typeof THUMB_TYPE];
|
||||
|
||||
export interface ThumbProps {
|
||||
data: ExplorerItem;
|
||||
size: null | number;
|
||||
cover?: boolean;
|
||||
className?: string;
|
||||
loadOriginal?: boolean;
|
||||
size?: number;
|
||||
cover?: boolean;
|
||||
frame?: boolean;
|
||||
mediaControls?: boolean;
|
||||
pauseVideo?: boolean;
|
||||
className?: string;
|
||||
childClassName?: string | ((type: ThumbType) => string | undefined);
|
||||
}
|
||||
|
||||
function FileThumb({ size, cover, ...props }: ThumbProps) {
|
||||
export const FileThumb = memo((props: ThumbProps) => {
|
||||
const isDark = useIsDark();
|
||||
const platform = usePlatform();
|
||||
|
||||
const itemData = useExplorerItemData(props.data);
|
||||
const locationData = getItemLocation(props.data);
|
||||
const filePath = getItemFilePath(props.data);
|
||||
const { library } = useLibraryContext();
|
||||
const [src, setSrc] = useState<null | string>(null);
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const [thumbType, setThumbType] = useState(ThumbType.Icon);
|
||||
|
||||
const { parent } = useExplorerContext();
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
const [src, setSrc] = useState<string>();
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const [thumbType, setThumbType] = useState<ThumbType>('icon');
|
||||
|
||||
const childClassName = 'max-h-full max-w-full object-contain';
|
||||
const frameClassName = clsx(
|
||||
'rounded-sm border-2 border-app-line bg-app-darkBox',
|
||||
isDark ? classes.checkers : classes.checkersLight
|
||||
);
|
||||
|
||||
const onLoad = () => setLoaded(true);
|
||||
|
||||
const onError = () => {
|
||||
setLoaded(false);
|
||||
setThumbType((prevThumbType) =>
|
||||
prevThumbType === 'original' && itemData.hasLocalThumbnail ? 'thumbnail' : 'icon'
|
||||
);
|
||||
};
|
||||
|
||||
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
|
||||
// thus avoiding improper thumb types changes
|
||||
useLayoutEffect(() => {
|
||||
// Reset src when item changes, to allow detection of yet not updated src
|
||||
setSrc(null);
|
||||
setSrc(undefined);
|
||||
setLoaded(false);
|
||||
|
||||
if (locationData) {
|
||||
setThumbType(ThumbType.Location);
|
||||
} else if (props.loadOriginal) {
|
||||
setThumbType(ThumbType.Original);
|
||||
} else if (itemData.hasLocalThumbnail) {
|
||||
setThumbType(ThumbType.Thumbnail);
|
||||
} else {
|
||||
setThumbType(ThumbType.Icon);
|
||||
}
|
||||
}, [props.loadOriginal, locationData, itemData]);
|
||||
if (props.loadOriginal) setThumbType('original');
|
||||
else if (itemData.hasLocalThumbnail) setThumbType('thumbnail');
|
||||
else setThumbType('icon');
|
||||
}, [props.loadOriginal, itemData]);
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
casId,
|
||||
kind,
|
||||
isDir,
|
||||
extension,
|
||||
locationId: itemLocationId,
|
||||
thumbnailKey
|
||||
} = itemData;
|
||||
const locationId =
|
||||
itemLocationId ?? (parent?.type === 'Location' ? parent.location.id : null);
|
||||
itemData.locationId ?? (parent?.type === 'Location' ? parent.location.id : null);
|
||||
|
||||
switch (thumbType) {
|
||||
case ThumbType.Original:
|
||||
if (locationId) {
|
||||
case 'original':
|
||||
if (locationId === null) setThumbType('thumbnail');
|
||||
else {
|
||||
setSrc(
|
||||
platform.getFileUrl(
|
||||
library.uuid,
|
||||
locationId,
|
||||
filePath?.id || props.data.item.id,
|
||||
// Workaround Linux webview not supporting playing video and audio through custom protocol urls
|
||||
kind == 'Video' || kind == 'Audio'
|
||||
itemData.kind == 'Video' || itemData.kind == 'Audio'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setThumbType(ThumbType.Thumbnail);
|
||||
}
|
||||
break;
|
||||
case ThumbType.Thumbnail:
|
||||
if (casId && thumbnailKey) {
|
||||
setSrc(platform.getThumbnailUrlByThumbKey(thumbnailKey));
|
||||
} else {
|
||||
setThumbType(ThumbType.Icon);
|
||||
}
|
||||
break;
|
||||
case ThumbType.Location:
|
||||
setSrc(getIcon('Folder', isDark, extension, true));
|
||||
|
||||
case 'thumbnail':
|
||||
if (!itemData.casId || !itemData.thumbnailKey) setThumbType('icon');
|
||||
else setSrc(platform.getThumbnailUrlByThumbKey(itemData.thumbnailKey));
|
||||
break;
|
||||
|
||||
default:
|
||||
if (isDir !== null) setSrc(getIcon(kind, isDark, extension, isDir));
|
||||
setSrc(
|
||||
getIcon(
|
||||
itemData.isDir ? 'Folder' : itemData.kind,
|
||||
isDark,
|
||||
itemData.extension,
|
||||
itemData.isDir
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}, [
|
||||
|
@ -195,54 +127,50 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
|
|||
parent
|
||||
]);
|
||||
|
||||
const onLoad = () => setLoaded(true);
|
||||
|
||||
const onError = () => {
|
||||
setLoaded(false);
|
||||
setThumbType((prevThumbType) => {
|
||||
return prevThumbType === ThumbType.Original && itemData.hasLocalThumbnail
|
||||
? ThumbType.Thumbnail
|
||||
: ThumbType.Icon;
|
||||
});
|
||||
};
|
||||
|
||||
const { kind, extension } = itemData;
|
||||
const childClassName = 'max-h-full max-w-full object-contain';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
visibility: loaded ? 'visible' : 'hidden',
|
||||
...(size ? { maxWidth: size, width: size - 10, height: size } : {})
|
||||
...(props.size
|
||||
? { maxWidth: props.size, width: props.size, height: props.size }
|
||||
: {})
|
||||
}}
|
||||
className={clsx(
|
||||
'relative flex shrink-0 items-center justify-center',
|
||||
size &&
|
||||
kind !== 'Video' &&
|
||||
thumbType !== ThumbType.Icon &&
|
||||
'border-2 border-transparent',
|
||||
size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'],
|
||||
loaded ? 'visible' : 'invisible',
|
||||
!props.size && 'h-full w-full',
|
||||
props.cover && 'overflow-hidden',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
if (src == null) return null;
|
||||
if (!src) return;
|
||||
|
||||
const className = clsx(
|
||||
childClassName,
|
||||
typeof props.childClassName === 'function'
|
||||
? props.childClassName(thumbType)
|
||||
: props.childClassName
|
||||
);
|
||||
|
||||
switch (thumbType) {
|
||||
case ThumbType.Original:
|
||||
switch (extension === 'pdf' && pdfViewerEnabled() ? 'PDF' : kind) {
|
||||
case 'original': {
|
||||
switch (itemData.extension === 'pdf' ? 'PDF' : itemData.kind) {
|
||||
case 'PDF':
|
||||
if (!pdfViewerEnabled()) return;
|
||||
return (
|
||||
<PDFViewer
|
||||
src={src}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
className={clsx(
|
||||
'h-full w-full border-0',
|
||||
childClassName,
|
||||
props.className
|
||||
'h-full w-full',
|
||||
className,
|
||||
props.frame && frameClassName
|
||||
)}
|
||||
crossOrigin="anonymous" // Here it is ok, because it is not a react attr
|
||||
/>
|
||||
);
|
||||
|
||||
case 'Text':
|
||||
return (
|
||||
<TEXTViewer
|
||||
|
@ -250,37 +178,41 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
|
|||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
className={clsx(
|
||||
'h-full w-full border-0 font-mono px-4',
|
||||
!props.mediaControls ? 'overflow-hidden' : 'overflow-auto',
|
||||
childClassName,
|
||||
props.className
|
||||
'h-full w-full px-4 font-mono',
|
||||
!props.mediaControls
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-auto',
|
||||
className,
|
||||
props.frame && [frameClassName, '!bg-none']
|
||||
)}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'Video':
|
||||
return (
|
||||
<Video
|
||||
src={src}
|
||||
onLoad={onLoad}
|
||||
onLoadedData={onLoad}
|
||||
onError={onError}
|
||||
paused={props.pauseVideo}
|
||||
controls={props.mediaControls}
|
||||
className={clsx(
|
||||
childClassName,
|
||||
size && 'rounded border-x-0 border-black',
|
||||
props.className
|
||||
)}
|
||||
className={clsx(className, props.frame && frameClassName)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'Audio':
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={getIcon(iconNames.Audio, isDark, extension)}
|
||||
src={getIcon(
|
||||
iconNames.Audio,
|
||||
isDark,
|
||||
itemData.extension
|
||||
)}
|
||||
onLoad={onLoad}
|
||||
decoding={size ? 'async' : 'sync'}
|
||||
className={clsx(childClassName, props.className)}
|
||||
decoding={props.size ? 'async' : 'sync'}
|
||||
className={childClassName}
|
||||
draggable={false}
|
||||
/>
|
||||
{props.mediaControls && (
|
||||
|
@ -299,50 +231,45 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
|
|||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case ThumbType.Thumbnail:
|
||||
case 'thumbnail':
|
||||
return (
|
||||
<Thumbnail
|
||||
src={src}
|
||||
cover={cover}
|
||||
cover={props.cover}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
decoding={size ? 'async' : 'sync'}
|
||||
decoding={props.size ? 'async' : 'sync'}
|
||||
className={clsx(
|
||||
cover
|
||||
props.cover
|
||||
? 'min-h-full min-w-full object-cover object-center'
|
||||
: childClassName,
|
||||
kind === 'Video' ? 'rounded' : 'rounded-sm',
|
||||
ThumbType.Original || [
|
||||
classes.checkers,
|
||||
'shadow shadow-black/30'
|
||||
],
|
||||
size &&
|
||||
(kind === 'Video'
|
||||
? 'border-x-0 border-black'
|
||||
: size > 60 && 'border-2 border-app-line'),
|
||||
props.className
|
||||
: className,
|
||||
|
||||
props.frame &&
|
||||
(itemData.kind !== 'Video' || thumbType == 'original')
|
||||
? frameClassName
|
||||
: null
|
||||
)}
|
||||
crossOrigin={ThumbType.Original && 'anonymous'} // Here it is ok, because it is not a react attr
|
||||
videoBarsSize={
|
||||
(kind === 'Video' && size && Math.floor(size / 10)) || 0
|
||||
}
|
||||
videoExtension={
|
||||
(kind === 'Video' &&
|
||||
(cover || size == null || size > 80) &&
|
||||
extension) ||
|
||||
''
|
||||
crossOrigin={thumbType !== 'original' ? 'anonymous' : undefined} // Here it is ok, because it is not a react attr
|
||||
videoBars={itemData.kind === 'Video' && !props.cover}
|
||||
extension={
|
||||
itemData.extension && itemData.kind === 'Video'
|
||||
? itemData.extension
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
onLoad={onLoad}
|
||||
onError={() => setLoaded(false)}
|
||||
decoding={size ? 'async' : 'sync'}
|
||||
className={clsx(childClassName, props.className)}
|
||||
decoding={props.size ? 'async' : 'sync'}
|
||||
className={childClassName}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
|
@ -350,53 +277,120 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
|
|||
})()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ThumbnailProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
cover?: boolean;
|
||||
videoBars?: boolean;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export default memo(FileThumb);
|
||||
const Thumbnail = memo(
|
||||
({
|
||||
crossOrigin,
|
||||
videoBars,
|
||||
extension,
|
||||
cover,
|
||||
onError,
|
||||
className,
|
||||
...props
|
||||
}: ThumbnailProps) => {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
|
||||
interface VideoProps {
|
||||
src: string;
|
||||
const [size, setSize] = useState<{ width: number; height: number }>();
|
||||
|
||||
useCallbackToWatchResize(({ width, height }) => setSize({ width, height }), [], ref);
|
||||
|
||||
const videoBarSize = (size: number) => Math.floor(size / 10);
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
// Order matter for crossOrigin attr
|
||||
// https://github.com/facebook/react/issues/14035#issuecomment-642227899
|
||||
{...(crossOrigin ? { crossOrigin } : {})}
|
||||
ref={ref}
|
||||
onError={(e) => {
|
||||
onError?.(e);
|
||||
setSize(undefined);
|
||||
}}
|
||||
draggable={false}
|
||||
className={clsx(className, videoBars && 'rounded border-black')}
|
||||
style={
|
||||
videoBars
|
||||
? size
|
||||
? size.height >= size.width
|
||||
? {
|
||||
borderLeftWidth: videoBarSize(size.height),
|
||||
borderRightWidth: videoBarSize(size.height)
|
||||
}
|
||||
: {
|
||||
borderTopWidth: videoBarSize(size.width),
|
||||
borderBottomWidth: videoBarSize(size.width)
|
||||
}
|
||||
: {}
|
||||
: {}
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{(cover || (size && size.width > 80)) && extension && (
|
||||
<div
|
||||
style={{
|
||||
...(!cover &&
|
||||
size && {
|
||||
marginTop: Math.floor(size.height / 2) - 2,
|
||||
marginLeft: Math.floor(size.width / 2) - 2
|
||||
})
|
||||
}}
|
||||
className={clsx(
|
||||
'absolute rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase text-white opacity-70',
|
||||
cover
|
||||
? 'bottom-1 right-1'
|
||||
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full'
|
||||
)}
|
||||
>
|
||||
{extension}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {
|
||||
paused?: boolean;
|
||||
controls?: boolean;
|
||||
className?: string;
|
||||
onLoad?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
const Video = memo(({ src, paused, controls, className, onLoad, onError }: VideoProps) => {
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
const Video = memo(({ paused, ...props }: VideoProps) => {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (video.current) {
|
||||
paused ? video.current.pause() : video.current.play();
|
||||
}
|
||||
if (!ref.current) return;
|
||||
paused ? ref.current.pause() : ref.current.play();
|
||||
}, [paused]);
|
||||
|
||||
return (
|
||||
<video
|
||||
// Order matter for crossOrigin attr
|
||||
crossOrigin="anonymous"
|
||||
ref={video}
|
||||
src={src}
|
||||
onError={onError}
|
||||
ref={ref}
|
||||
autoPlay={!paused}
|
||||
onVolumeChange={(e) => {
|
||||
const video = e.target as HTMLVideoElement;
|
||||
getExplorerStore().mediaPlayerVolume = video.volume;
|
||||
}}
|
||||
controls={controls}
|
||||
onCanPlay={(e) => {
|
||||
const video = e.target as HTMLVideoElement;
|
||||
// Why not use the element's attribute? Because React...
|
||||
// https://github.com/facebook/react/issues/10389
|
||||
video.loop = !controls;
|
||||
video.muted = !controls;
|
||||
video.loop = !props.controls;
|
||||
video.muted = !props.controls;
|
||||
video.volume = getExplorerStore().mediaPlayerVolume;
|
||||
}}
|
||||
className={className}
|
||||
playsInline
|
||||
onLoadedData={onLoad}
|
||||
draggable={false}
|
||||
{...props}
|
||||
>
|
||||
<p>Video preview is not supported.</p>
|
||||
</video>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
// import types from '../../constants/file-types.json';
|
||||
import { Image, Image_Light } from '@sd/assets/icons';
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
|
@ -7,105 +6,148 @@ import {
|
|||
CircleWavyCheck,
|
||||
Clock,
|
||||
Cube,
|
||||
Eraser,
|
||||
FolderOpen,
|
||||
Hash,
|
||||
Icon,
|
||||
Link,
|
||||
Lock,
|
||||
Path,
|
||||
Snowflake
|
||||
} from 'phosphor-react';
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
import { HTMLAttributes, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ExplorerItem,
|
||||
Location,
|
||||
ObjectKind,
|
||||
Tag,
|
||||
byteSize,
|
||||
bytesToNumber,
|
||||
getItemFilePath,
|
||||
getItemObject,
|
||||
isPath,
|
||||
useItemsAsObjects,
|
||||
useLibraryQuery
|
||||
} from '@sd/client';
|
||||
import { Button, Divider, DropdownMenu, Tooltip, tw } from '@sd/ui';
|
||||
import AssignTagMenuItems from '~/components/AssignTagMenuItems';
|
||||
import { useIsDark } from '~/hooks';
|
||||
import AssignTagMenuItems from '../ContextMenu/Object/AssignTagMenuItems';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { useExplorerStore } from '../store.js';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { stringify } from '~/util/uuid';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { useExplorerStore } from '../store';
|
||||
import FavoriteButton from './FavoriteButton';
|
||||
import Note from './Note';
|
||||
|
||||
export const InfoPill = tw.span`inline border border-transparent px-1 text-[11px] font-medium shadow shadow-app-shade/5 bg-app-selected rounded-md text-ink-dull`;
|
||||
export const PlaceholderPill = tw.span`inline border px-1 text-[11px] shadow shadow-app-shade/10 rounded-md bg-transparent border-dashed border-app-active transition hover:text-ink-faint hover:border-ink-faint font-medium text-ink-faint/70`;
|
||||
|
||||
export const MetaContainer = tw.div`flex flex-col px-4 py-1.5`;
|
||||
export const MetaContainer = tw.div`flex flex-col px-4 py-2 gap-1`;
|
||||
export const MetaTitle = tw.h5`text-xs font-bold`;
|
||||
export const MetaKeyName = tw.h5`text-xs flex-shrink-0 flex-wrap-0`;
|
||||
export const MetaValue = tw.p`text-xs break-all text-ink truncate`;
|
||||
|
||||
const MetaTextLine = tw.div`flex items-center my-0.5 text-xs text-ink-dull`;
|
||||
|
||||
const InspectorIcon = ({ component: Icon, ...props }: any) => (
|
||||
<Icon weight="bold" {...props} className={clsx('mr-2 shrink-0', props.className)} />
|
||||
);
|
||||
const DATE_FORMAT = 'D MMM YYYY';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
context?: Location | Tag;
|
||||
data?: ExplorerItem;
|
||||
showThumbnail?: boolean;
|
||||
}
|
||||
|
||||
export const Inspector = ({ data, context, showThumbnail = true, ...props }: Props) => {
|
||||
export const Inspector = ({ showThumbnail = true, ...props }: Props) => {
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const isDark = useIsDark();
|
||||
const objectData = data ? getItemObject(data) : null;
|
||||
const filePathData = data ? getItemFilePath(data) : null;
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const isDir = data?.type === 'Path' ? data.item.is_dir : false;
|
||||
|
||||
// this prevents the inspector from fetching data when the user is navigating quickly
|
||||
const [readyToFetch, setReadyToFetch] = useState(false);
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setReadyToFetch(true);
|
||||
}, 350);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [data?.item.id]);
|
||||
|
||||
// this is causing LAG
|
||||
const tags = useLibraryQuery(['tags.getForObject', objectData?.id || -1], {
|
||||
enabled: readyToFetch
|
||||
});
|
||||
|
||||
const fullObjectData = useLibraryQuery(['files.get', { id: objectData?.id || -1 }], {
|
||||
enabled: readyToFetch && objectData?.id !== undefined
|
||||
});
|
||||
|
||||
const item = data?.item;
|
||||
|
||||
const { data: fileFullPath } = useLibraryQuery(['files.getPath', item?.id || -1]);
|
||||
|
||||
// map array of numbers into string
|
||||
const pub_id = fullObjectData?.data?.pub_id.map((n: number) => n.toString(16)).join('');
|
||||
const selectedItems = useMemo(() => [...explorer.selectedItems], [explorer.selectedItems]);
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
{item ? (
|
||||
<>
|
||||
{showThumbnail && (
|
||||
<div className="mb-2 aspect-square">
|
||||
<FileThumb
|
||||
pauseVideo={!!explorerStore.quickViewObject}
|
||||
loadOriginal
|
||||
size={null}
|
||||
data={data}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="relative mb-2 flex aspect-square items-center justify-center px-2">
|
||||
{isNonEmpty(selectedItems) ? (
|
||||
<Thumbnails items={selectedItems} />
|
||||
) : (
|
||||
<img src={isDark ? Image : Image_Light} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full select-text flex-col overflow-hidden rounded-lg border border-app-line bg-app-box py-0.5 shadow-app-shade/10">
|
||||
|
||||
<div className="flex select-text flex-col overflow-hidden rounded-lg border border-app-line bg-app-box py-0.5 shadow-app-shade/10">
|
||||
{!isNonEmpty(selectedItems) ? (
|
||||
<div className="flex h-[390px] items-center justify-center text-sm text-ink-dull">
|
||||
Nothing selected
|
||||
</div>
|
||||
) : selectedItems.length === 1 ? (
|
||||
<SingleItemMetadata item={selectedItems[0]} />
|
||||
) : (
|
||||
<MultiItemMetadata items={selectedItems} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Thumbnails = ({ items }: { items: ExplorerItem[] }) => {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const lastThreeItems = items.slice(-3).reverse();
|
||||
|
||||
return (
|
||||
<>
|
||||
{lastThreeItems.map((item, i, thumbs) => (
|
||||
<FileThumb
|
||||
key={item.item.id}
|
||||
loadOriginal
|
||||
data={item}
|
||||
className={clsx(
|
||||
thumbs.length > 1 && '!absolute',
|
||||
i === 0 && thumbs.length > 1 && 'z-30 !h-[76%] !w-[76%]',
|
||||
i === 1 && 'z-20 !h-[80%] !w-[80%] rotate-[-5deg]',
|
||||
i === 2 && 'z-10 !h-[84%] !w-[84%] rotate-[7deg]'
|
||||
)}
|
||||
pauseVideo={!!explorerStore.quickViewObject || thumbs.length > 1}
|
||||
frame={thumbs.length > 1}
|
||||
childClassName={(type) =>
|
||||
type !== 'icon' && thumbs.length > 1
|
||||
? 'shadow-md shadow-app-shade'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
||||
const filePathData = getItemFilePath(item);
|
||||
const objectData = getItemObject(item);
|
||||
|
||||
const isDir = item.type === 'Path' && item.item.is_dir;
|
||||
|
||||
const readyToFetch = useIsFetchReady(item);
|
||||
|
||||
const tags = useLibraryQuery(['tags.getForObject', objectData?.id || -1], {
|
||||
enabled: readyToFetch && !!objectData
|
||||
});
|
||||
|
||||
const object = useLibraryQuery(['files.get', { id: objectData?.id || -1 }], {
|
||||
enabled: readyToFetch && !!objectData
|
||||
});
|
||||
|
||||
const fileFullPath = useLibraryQuery(['files.getPath', filePathData?.id || -1], {
|
||||
enabled: readyToFetch && !!filePathData
|
||||
});
|
||||
|
||||
const pubId = useMemo(
|
||||
() => (object?.data?.pub_id ? stringify(object.data.pub_id) : null),
|
||||
[object?.data?.pub_id]
|
||||
);
|
||||
|
||||
const formatDate = (date: string | null | undefined) => date && dayjs(date).format(DATE_FORMAT);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="truncate px-3 pb-1 pt-2 text-base font-bold">
|
||||
{filePathData?.name}
|
||||
{filePathData?.extension && `.${filePathData.extension}`}
|
||||
</h3>
|
||||
|
||||
{objectData && (
|
||||
<div className="mx-3 mb-0.5 mt-1 flex flex-row space-x-0.5">
|
||||
<Tooltip label="Favorite">
|
||||
|
@ -124,31 +166,55 @@ export const Inspector = ({ data, context, showThumbnail = true, ...props }: Pro
|
|||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{isPath(data) && context && 'path' in context && (
|
||||
<MetaContainer>
|
||||
<MetaTitle>URI</MetaTitle>
|
||||
<MetaValue>
|
||||
{`${context.path}/${data.item.materialized_path}${
|
||||
data.item.name
|
||||
}${data.item.is_dir ? `.${data.item.extension}` : '/'}`}
|
||||
</MetaValue>
|
||||
</MetaContainer>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<MetaContainer>
|
||||
<div className="flex flex-wrap gap-1 overflow-hidden">
|
||||
<InfoPill>
|
||||
{isDir ? 'Folder' : ObjectKind[objectData?.kind || 0]}
|
||||
</InfoPill>
|
||||
{filePathData?.extension && (
|
||||
<InfoPill>{filePathData.extension}</InfoPill>
|
||||
)}
|
||||
<MetaData
|
||||
icon={Cube}
|
||||
label="Size"
|
||||
value={`${byteSize(filePathData?.size_in_bytes_bytes)}`}
|
||||
/>
|
||||
|
||||
<MetaData icon={Clock} label="Created" value={formatDate(item.item.date_created)} />
|
||||
|
||||
<MetaData
|
||||
icon={Eraser}
|
||||
label="Modified"
|
||||
value={formatDate(filePathData?.date_modified)}
|
||||
/>
|
||||
|
||||
<MetaData
|
||||
icon={Barcode}
|
||||
label="Indexed"
|
||||
value={formatDate(filePathData?.date_indexed)}
|
||||
/>
|
||||
<MetaData
|
||||
icon={FolderOpen}
|
||||
label="Accessed"
|
||||
value={formatDate(objectData?.date_accessed)}
|
||||
/>
|
||||
|
||||
<MetaData
|
||||
icon={Path}
|
||||
label="Path"
|
||||
value={fileFullPath.data}
|
||||
onClick={() => {
|
||||
// TODO: Add toast notification
|
||||
fileFullPath.data && navigator.clipboard.writeText(fileFullPath.data);
|
||||
}}
|
||||
/>
|
||||
</MetaContainer>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MetaContainer className="flex !flex-row flex-wrap gap-1 overflow-hidden">
|
||||
<InfoPill>{isDir ? 'Folder' : ObjectKind[objectData?.kind || 0]}</InfoPill>
|
||||
|
||||
{filePathData?.extension && <InfoPill>{filePathData.extension}</InfoPill>}
|
||||
|
||||
{tags.data?.map((tag) => (
|
||||
<Tooltip
|
||||
key={tag.id}
|
||||
label={tag.name || ''}
|
||||
className="flex overflow-hidden"
|
||||
>
|
||||
<Tooltip key={tag.id} label={tag.name || ''} className="flex overflow-hidden">
|
||||
<InfoPill
|
||||
className="truncate !text-white"
|
||||
style={{ backgroundColor: tag.color + 'CC' }}
|
||||
|
@ -157,128 +223,247 @@ export const Inspector = ({ data, context, showThumbnail = true, ...props }: Pro
|
|||
</InfoPill>
|
||||
</Tooltip>
|
||||
))}
|
||||
{objectData?.id && (
|
||||
|
||||
{objectData && (
|
||||
<DropdownMenu.Root
|
||||
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
alignOffset={-10}
|
||||
>
|
||||
<AssignTagMenuItems objectId={objectData.id} />
|
||||
<AssignTagMenuItems objects={[objectData]} />
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
</div>
|
||||
</MetaContainer>
|
||||
<Divider />
|
||||
<MetaContainer className="!flex-row space-x-2">
|
||||
{filePathData?.size_in_bytes_bytes && (
|
||||
<MetaTextLine>
|
||||
<InspectorIcon component={Cube} />
|
||||
<span className="mr-1.5">Size</span>
|
||||
<MetaValue>
|
||||
{`${byteSize(filePathData.size_in_bytes_bytes)}`}
|
||||
</MetaValue>
|
||||
</MetaTextLine>
|
||||
)}
|
||||
{fullObjectData.data?.media_data?.duration_seconds && (
|
||||
<MetaTextLine>
|
||||
<InspectorIcon component={Clock} />
|
||||
<span className="mr-1.5">Duration</span>
|
||||
<MetaValue>
|
||||
{fullObjectData.data.media_data.duration_seconds}
|
||||
</MetaValue>
|
||||
</MetaTextLine>
|
||||
)}
|
||||
</MetaContainer>
|
||||
<Divider />
|
||||
<MetaContainer>
|
||||
<Tooltip label={dayjs(item.date_created).format('h:mm:ss a')}>
|
||||
<MetaTextLine>
|
||||
<InspectorIcon component={Clock} />
|
||||
<MetaKeyName className="mr-1.5">Created</MetaKeyName>
|
||||
<MetaValue>
|
||||
{dayjs(item.date_created).format('MMM Do YYYY')}
|
||||
</MetaValue>
|
||||
</MetaTextLine>
|
||||
</Tooltip>
|
||||
{filePathData && (
|
||||
<Tooltip
|
||||
label={dayjs(filePathData.date_indexed).format('h:mm:ss a')}
|
||||
>
|
||||
<MetaTextLine>
|
||||
<InspectorIcon component={Barcode} />
|
||||
<MetaKeyName className="mr-1.5">Indexed</MetaKeyName>
|
||||
<MetaValue>
|
||||
{dayjs(filePathData?.date_indexed).format(
|
||||
'MMM Do YYYY'
|
||||
)}
|
||||
</MetaValue>
|
||||
</MetaTextLine>
|
||||
</Tooltip>
|
||||
)}
|
||||
{fileFullPath && (
|
||||
<Tooltip label={fileFullPath}>
|
||||
<MetaTextLine>
|
||||
<InspectorIcon component={Path} />
|
||||
<MetaKeyName className="mr-1.5">Path</MetaKeyName>
|
||||
<MetaValue>{fileFullPath}</MetaValue>
|
||||
</MetaTextLine>
|
||||
</Tooltip>
|
||||
)}
|
||||
</MetaContainer>
|
||||
|
||||
{!isDir && objectData && (
|
||||
<>
|
||||
<Note data={objectData} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<MetaContainer>
|
||||
<Tooltip label={filePathData?.cas_id || ''}>
|
||||
<MetaTextLine>
|
||||
<InspectorIcon component={Snowflake} />
|
||||
<MetaKeyName className="mr-1.5">Content ID</MetaKeyName>
|
||||
<MetaValue>{filePathData?.cas_id || ''}</MetaValue>
|
||||
</MetaTextLine>
|
||||
</Tooltip>
|
||||
<MetaData
|
||||
icon={Snowflake}
|
||||
label="Content ID"
|
||||
value={filePathData?.cas_id}
|
||||
/>
|
||||
|
||||
{filePathData?.integrity_checksum && (
|
||||
<Tooltip label={filePathData?.integrity_checksum || ''}>
|
||||
<MetaTextLine>
|
||||
<InspectorIcon component={CircleWavyCheck} />
|
||||
<MetaKeyName className="mr-1.5">
|
||||
Checksum
|
||||
</MetaKeyName>
|
||||
<MetaValue>
|
||||
{filePathData?.integrity_checksum}
|
||||
</MetaValue>
|
||||
</MetaTextLine>
|
||||
</Tooltip>
|
||||
)}
|
||||
{pub_id && (
|
||||
<Tooltip label={pub_id || ''}>
|
||||
<MetaTextLine>
|
||||
<InspectorIcon component={Hash} />
|
||||
<MetaKeyName className="mr-1.5">
|
||||
Object ID
|
||||
</MetaKeyName>
|
||||
<MetaValue>{pub_id}</MetaValue>
|
||||
</MetaTextLine>
|
||||
</Tooltip>
|
||||
<MetaData
|
||||
icon={CircleWavyCheck}
|
||||
label="Checksum"
|
||||
value={filePathData.integrity_checksum}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MetaData icon={Hash} label="Object ID" value={pubId} />
|
||||
</MetaContainer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center">
|
||||
<img src={isDark ? Image : Image_Light} />
|
||||
<div
|
||||
className="mt-[15px] flex h-[390px] w-[245px] select-text items-center justify-center
|
||||
rounded-lg border border-app-line bg-app-box py-0.5 shadow-app-shade/10"
|
||||
);
|
||||
};
|
||||
|
||||
type MetadataDate = Date | { from: Date; to: Date } | null;
|
||||
|
||||
const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const selectedObjects = useItemsAsObjects(items);
|
||||
|
||||
const readyToFetch = useIsFetchReady(items);
|
||||
|
||||
const tags = useLibraryQuery(['tags.list'], {
|
||||
enabled: readyToFetch && !explorerStore.isDragging,
|
||||
suspense: true
|
||||
});
|
||||
|
||||
const tagsWithObjects = useLibraryQuery(
|
||||
['tags.getWithObjects', selectedObjects.map(({ id }) => id)],
|
||||
{ enabled: readyToFetch && !explorerStore.isDragging }
|
||||
);
|
||||
|
||||
const formatDate = (metadataDate: MetadataDate) => {
|
||||
if (!metadataDate) return;
|
||||
|
||||
if (metadataDate instanceof Date) return dayjs(metadataDate).format(DATE_FORMAT);
|
||||
|
||||
const { from, to } = metadataDate;
|
||||
|
||||
const sameMonth = from.getMonth() === to.getMonth();
|
||||
const sameYear = from.getFullYear() === to.getFullYear();
|
||||
|
||||
const format = ['D', !sameMonth && 'MMM', !sameYear && 'YYYY'].filter(Boolean).join(' ');
|
||||
|
||||
return `${dayjs(from).format(format)} - ${dayjs(to).format(DATE_FORMAT)}`;
|
||||
};
|
||||
|
||||
const getDate = useCallback((metadataDate: MetadataDate, date: Date) => {
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
if (!metadataDate) {
|
||||
metadataDate = date;
|
||||
} else if (metadataDate instanceof Date && date.getTime() !== metadataDate.getTime()) {
|
||||
metadataDate = { from: metadataDate, to: date };
|
||||
} else if ('from' in metadataDate && date < metadataDate.from) {
|
||||
metadataDate.from = date;
|
||||
} else if ('to' in metadataDate && date > metadataDate.to) {
|
||||
metadataDate.to = date;
|
||||
}
|
||||
|
||||
return metadataDate;
|
||||
}, []);
|
||||
|
||||
const metadata = useMemo(
|
||||
() =>
|
||||
items.reduce(
|
||||
(metadata, item) => {
|
||||
const filePathData = getItemFilePath(item);
|
||||
const objectData = getItemObject(item);
|
||||
|
||||
if (filePathData?.size_in_bytes_bytes) {
|
||||
metadata.size += bytesToNumber(filePathData.size_in_bytes_bytes);
|
||||
}
|
||||
|
||||
if (filePathData?.date_created) {
|
||||
metadata.created = getDate(
|
||||
metadata.created,
|
||||
new Date(filePathData.date_created)
|
||||
);
|
||||
}
|
||||
|
||||
if (filePathData?.date_modified) {
|
||||
metadata.modified = getDate(
|
||||
metadata.modified,
|
||||
new Date(filePathData.date_modified)
|
||||
);
|
||||
}
|
||||
|
||||
if (filePathData?.date_indexed) {
|
||||
metadata.indexed = getDate(
|
||||
metadata.indexed,
|
||||
new Date(filePathData.date_indexed)
|
||||
);
|
||||
}
|
||||
|
||||
if (objectData?.date_accessed) {
|
||||
metadata.accessed = getDate(
|
||||
metadata.accessed,
|
||||
new Date(objectData.date_accessed)
|
||||
);
|
||||
}
|
||||
|
||||
const kind =
|
||||
item.type === 'Path' && item.item.is_dir
|
||||
? 'Folder'
|
||||
: ObjectKind[objectData?.kind || 0];
|
||||
|
||||
if (kind) {
|
||||
const kindItems = metadata.kinds.get(kind);
|
||||
if (!kindItems) metadata.kinds.set(kind, [item]);
|
||||
else metadata.kinds.set(kind, [...kindItems, item]);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
},
|
||||
{ size: BigInt(0), indexed: null, kinds: new Map() } as {
|
||||
size: bigint;
|
||||
created: MetadataDate;
|
||||
modified: MetadataDate;
|
||||
indexed: MetadataDate;
|
||||
accessed: MetadataDate;
|
||||
kinds: Map<string, ExplorerItem[]>;
|
||||
}
|
||||
),
|
||||
[items, getDate]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetaContainer>
|
||||
<MetaData icon={Cube} label="Size" value={`${byteSize(metadata.size)}`} />
|
||||
<MetaData icon={Clock} label="Created" value={formatDate(metadata.created)} />
|
||||
<MetaData icon={Eraser} label="Modified" value={formatDate(metadata.modified)} />
|
||||
<MetaData icon={Barcode} label="Indexed" value={formatDate(metadata.indexed)} />
|
||||
<MetaData
|
||||
icon={FolderOpen}
|
||||
label="Accessed"
|
||||
value={formatDate(metadata.accessed)}
|
||||
/>
|
||||
</MetaContainer>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MetaContainer className="flex !flex-row flex-wrap gap-1 overflow-hidden">
|
||||
{[...metadata.kinds].map(([kind, items]) => (
|
||||
<InfoPill key={kind}>{`${kind} (${items.length})`}</InfoPill>
|
||||
))}
|
||||
|
||||
{tags.data?.map((tag) => {
|
||||
const objectsWithTag = tagsWithObjects.data?.[tag.id] || [];
|
||||
|
||||
if (objectsWithTag.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Tooltip key={tag.id} label={tag.name} className="flex overflow-hidden">
|
||||
<InfoPill
|
||||
className="truncate !text-white"
|
||||
style={{
|
||||
backgroundColor: tag.color + 'CC',
|
||||
opacity:
|
||||
objectsWithTag.length === selectedObjects.length ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-ink-dull">Nothing selected</p>
|
||||
</div>
|
||||
</div>
|
||||
{tag.name} ({objectsWithTag.length})
|
||||
</InfoPill>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{isNonEmpty(selectedObjects) && (
|
||||
<DropdownMenu.Root
|
||||
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
alignOffset={-10}
|
||||
>
|
||||
<AssignTagMenuItems objects={selectedObjects} />
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
</MetaContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface MetaDataProps {
|
||||
icon: Icon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const MetaData = ({ icon: Icon, label, value, onClick }: MetaDataProps) => {
|
||||
return (
|
||||
<div className="flex items-center text-xs text-ink-dull" onClick={onClick}>
|
||||
<Icon weight="bold" className="mr-2 shrink-0" />
|
||||
<span className="mr-2 flex-1 whitespace-nowrap">{label}</span>
|
||||
<Tooltip label={value} asChild>
|
||||
<span className="truncate break-all text-ink">{value || '--'}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useIsFetchReady = (item: ExplorerItem | ExplorerItem[]) => {
|
||||
const [readyToFetch, setReadyToFetch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setReadyToFetch(false);
|
||||
|
||||
const timeout = setTimeout(() => setReadyToFetch(true), 350);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [item]);
|
||||
|
||||
return readyToFetch;
|
||||
};
|
||||
|
|
|
@ -49,6 +49,22 @@ export default () => {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explorerStore.layoutMode === 'grid' && (
|
||||
<div>
|
||||
<Subheading>Gap</Subheading>
|
||||
<Slider
|
||||
onValueChange={([val]) => {
|
||||
if (val) getExplorerStore().gridGap = val;
|
||||
}}
|
||||
defaultValue={[explorerStore.gridGap]}
|
||||
max={16}
|
||||
min={4}
|
||||
step={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(explorerStore.layoutMode === 'grid' || explorerStore.layoutMode === 'media') && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
|
|
|
@ -6,8 +6,8 @@ import { showAlertDialog } from '~/components';
|
|||
import { useOperatingSystem } from '~/hooks';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
import { useExplorerContext } from './Context';
|
||||
import { SharedItems } from './ContextMenu';
|
||||
import { CopyAsPath } from './ContextMenu/FilePath/Items';
|
||||
import { CopyAsPathBase } from './CopyAsPath';
|
||||
import { RevealInNativeExplorerBase } from './RevealInNativeExplorer';
|
||||
import { getExplorerStore, useExplorerStore } from './store';
|
||||
import { useExplorerSearchParams } from './util';
|
||||
|
||||
|
@ -27,23 +27,25 @@ export default (props: PropsWithChildren) => {
|
|||
|
||||
return (
|
||||
<CM.Root trigger={props.children}>
|
||||
{parent?.type === 'Location' && cutCopyState.active && (
|
||||
{parent?.type === 'Location' && cutCopyState.type !== 'Idle' && (
|
||||
<>
|
||||
<CM.Item
|
||||
label="Paste"
|
||||
keybind={keybind([ModifierKeys.Control], ['V'])}
|
||||
onClick={async () => {
|
||||
const path = currentPath ?? '/';
|
||||
const { actionType, sourcePathId, sourceParentPath, sourceLocationId } =
|
||||
const { type, sourcePathIds, sourceParentPath, sourceLocationId } =
|
||||
cutCopyState;
|
||||
|
||||
const sameLocation =
|
||||
sourceLocationId === parent.location.id &&
|
||||
sourceParentPath === path;
|
||||
|
||||
try {
|
||||
if (actionType == 'Copy') {
|
||||
if (type == 'Copy') {
|
||||
await copyFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [sourcePathId],
|
||||
sources_file_path_ids: [...sourcePathIds],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path,
|
||||
target_file_name_suffix: sameLocation ? ' copy' : null
|
||||
|
@ -56,7 +58,7 @@ export default (props: PropsWithChildren) => {
|
|||
} else {
|
||||
await cutFiles.mutateAsync({
|
||||
source_location_id: sourceLocationId,
|
||||
sources_file_path_ids: [sourcePathId],
|
||||
sources_file_path_ids: [...sourcePathIds],
|
||||
target_location_id: parent.location.id,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
|
@ -64,7 +66,7 @@ export default (props: PropsWithChildren) => {
|
|||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to ${actionType.toLowerCase()} file, due to an error: ${error}`
|
||||
value: `Failed to ${type.toLowerCase()} file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
@ -75,8 +77,7 @@ export default (props: PropsWithChildren) => {
|
|||
label="Deselect"
|
||||
onClick={() => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
...cutCopyState,
|
||||
active: false
|
||||
type: 'Idle'
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
|
@ -103,10 +104,11 @@ export default (props: PropsWithChildren) => {
|
|||
|
||||
{parent?.type === 'Location' && (
|
||||
<>
|
||||
<SharedItems.RevealInNativeExplorer locationId={parent.location.id} />
|
||||
|
||||
<RevealInNativeExplorerBase
|
||||
items={[{ Location: { id: parent.location.id } }]}
|
||||
/>
|
||||
<CM.SubMenu label="More actions..." icon={Plus}>
|
||||
<CopyAsPath pathOrId={`${parent.location.path}${currentPath ?? ''}`} />
|
||||
<CopyAsPathBase path={`${parent.location.path}${currentPath ?? ''}`} />
|
||||
|
||||
<CM.Item
|
||||
onClick={async () => {
|
||||
|
|
|
@ -1,131 +0,0 @@
|
|||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { X } from 'phosphor-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { subscribeKey } from 'valtio/utils';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { getExplorerStore } from '../store';
|
||||
|
||||
const AnimatedDialogOverlay = animated(Dialog.Overlay);
|
||||
const AnimatedDialogContent = animated(Dialog.Content);
|
||||
|
||||
export interface QuickPreviewProps extends Dialog.DialogProps {
|
||||
transformOrigin?: string;
|
||||
}
|
||||
|
||||
export function QuickPreview({ transformOrigin }: QuickPreviewProps) {
|
||||
const explorerItem = useRef<null | ExplorerItem>(null);
|
||||
const explorerStore = getExplorerStore();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* The useEffect hook with subscribe is used here, instead of useExplorerStore, because when
|
||||
* explorerStore.quickViewObject is set to null the component will not close immediately.
|
||||
* Instead, it will enter the beginning of the close transition and it must continue to display
|
||||
* content for a few more seconds due to the ongoing animation. To handle this, the open state
|
||||
* is decoupled from the store state, by assigning references to the required store properties
|
||||
* to render the component in the subscribe callback.
|
||||
*/
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeKey(explorerStore, 'quickViewObject', () => {
|
||||
const { quickViewObject } = explorerStore;
|
||||
if (quickViewObject != null) {
|
||||
setIsOpen(true);
|
||||
explorerItem.current = quickViewObject;
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}),
|
||||
[explorerStore]
|
||||
);
|
||||
|
||||
const transitions = useTransition(isOpen, {
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: `translateY(20px) scale(0.9)`,
|
||||
transformOrigin: transformOrigin || 'center top'
|
||||
},
|
||||
enter: { opacity: 1, transform: `translateY(0px) scale(1)` },
|
||||
leave: { opacity: 0, transform: `translateY(40px) scale(0.9)` },
|
||||
config: { mass: 0.2, tension: 300, friction: 20, bounce: 0 }
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) explorerStore.quickViewObject = null;
|
||||
}}
|
||||
>
|
||||
{transitions((styles, show) => {
|
||||
if (!show || explorerItem.current == null) return null;
|
||||
|
||||
const { item } = explorerItem.current;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Portal forceMount>
|
||||
<AnimatedDialogOverlay
|
||||
style={{
|
||||
opacity: styles.opacity
|
||||
}}
|
||||
className="z-49 absolute inset-0 m-[1px] grid place-items-center overflow-y-auto rounded-xl bg-app/50"
|
||||
/>
|
||||
<AnimatedDialogContent
|
||||
style={styles}
|
||||
className="!pointer-events-none absolute inset-0 z-50 grid h-screen place-items-center"
|
||||
>
|
||||
<div className="!pointer-events-auto flex h-5/6 max-h-screen w-11/12 flex-col rounded-md border border-app-line bg-app-box text-ink shadow-app-shade">
|
||||
<nav className="relative flex w-full flex-row">
|
||||
<Dialog.Close
|
||||
asChild
|
||||
className="absolute m-2"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="flex flex-row"
|
||||
>
|
||||
<X
|
||||
weight="bold"
|
||||
className=" h-3 w-3 text-ink-faint"
|
||||
/>
|
||||
<span className="ml-1 text-tiny font-medium text-ink-faint">
|
||||
ESC
|
||||
</span>
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Title className="mx-auto my-2 font-bold">
|
||||
Preview -{' '}
|
||||
<span className="inline-block max-w-xs truncate align-sub text-sm text-ink-dull">
|
||||
{'name' in item && item.name
|
||||
? item.name
|
||||
: 'Unkown Object'}
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
</nav>
|
||||
<div className="flex h-full w-full shrink items-center justify-center overflow-hidden">
|
||||
<FileThumb
|
||||
size={0}
|
||||
data={explorerItem.current}
|
||||
className="w-full"
|
||||
loadOriginal
|
||||
mediaControls
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedDialogContent>
|
||||
</Dialog.Portal>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Dialog.Root>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import { subscribeKey } from 'valtio/utils';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { getExplorerStore } from '../store';
|
||||
|
||||
const AnimatedDialogOverlay = animated(Dialog.Overlay);
|
||||
|
@ -106,15 +106,13 @@ export function QuickPreview({ transformOrigin }: QuickPreviewProps) {
|
|||
<span className="inline-block max-w-xs truncate align-sub text-sm text-ink-dull">
|
||||
{'name' in item && item.name
|
||||
? item.name
|
||||
: 'Unkown Object'}
|
||||
: 'Unknown Object'}
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
</nav>
|
||||
<div className="flex h-full w-full shrink items-center justify-center overflow-hidden">
|
||||
<FileThumb
|
||||
size={0}
|
||||
data={explorerItem.current}
|
||||
className="w-full"
|
||||
loadOriginal
|
||||
mediaControls
|
||||
/>
|
||||
|
|
33
interface/app/$libraryId/Explorer/RevealInNativeExplorer.tsx
Normal file
33
interface/app/$libraryId/Explorer/RevealInNativeExplorer.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useLibraryContext } from '@sd/client';
|
||||
import { ContextMenu, ModifierKeys } from '@sd/ui';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
|
||||
import { NonEmptyArray } from '~/util';
|
||||
import { Platform, usePlatform } from '~/util/Platform';
|
||||
|
||||
const lookup: Record<string, string> = {
|
||||
macOS: 'Finder',
|
||||
windows: 'Explorer'
|
||||
};
|
||||
|
||||
export type RevealItems = NonEmptyArray<
|
||||
Parameters<NonNullable<Platform['revealItems']>>[1][number]
|
||||
>;
|
||||
|
||||
export const RevealInNativeExplorerBase = (props: { items: RevealItems }) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = useKeybindFactory();
|
||||
const { library } = useLibraryContext();
|
||||
const { revealItems } = usePlatform();
|
||||
if (!revealItems) return null;
|
||||
|
||||
const osFileBrowserName = lookup[os] ?? 'file manager';
|
||||
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
label={`Reveal in ${osFileBrowserName}`}
|
||||
keybind={keybind([ModifierKeys.Control], ['Y'])}
|
||||
onClick={() => revealItems(library.uuid, props.items)}
|
||||
/>
|
||||
);
|
||||
};
|
601
interface/app/$libraryId/Explorer/View/GridList.tsx
Normal file
601
interface/app/$libraryId/Explorer/View/GridList.tsx
Normal file
|
@ -0,0 +1,601 @@
|
|||
import { ReactNode, createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Selecto from 'react-selecto';
|
||||
import { useKey } from 'rooks';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { GridList, useGridList } from '~/components';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { getExplorerStore, isCut, useExplorerStore } from '../store';
|
||||
import { ExplorerItemHash } from '../useExplorer';
|
||||
import { explorerItemHash } from '../util';
|
||||
|
||||
const SelectoContext = createContext<{
|
||||
selecto: React.RefObject<Selecto>;
|
||||
selectoUnSelected: React.MutableRefObject<Set<ExplorerItemHash>>;
|
||||
} | null>(null);
|
||||
|
||||
type RenderItem = (item: { item: ExplorerItem; selected: boolean; cut: boolean }) => ReactNode;
|
||||
|
||||
const GridListItem = (props: {
|
||||
index: number;
|
||||
item: ExplorerItem;
|
||||
children: RenderItem;
|
||||
onMouseDown: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
}) => {
|
||||
const explorer = useExplorerContext();
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
const selecto = useContext(SelectoContext);
|
||||
|
||||
const cut = isCut(props.item.item.id);
|
||||
|
||||
const selected = useMemo(
|
||||
// Even though this checks object equality, it should still be safe since `selectedItems`
|
||||
// will be re-calculated before this memo runs.
|
||||
() => explorer.selectedItems.has(props.item),
|
||||
[explorer.selectedItems, props.item]
|
||||
);
|
||||
|
||||
const hash = explorerItemHash(props.item);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecto?.selecto.current || !selecto.selectoUnSelected.current.has(hash)) return;
|
||||
|
||||
if (!selected) {
|
||||
selecto.selectoUnSelected.current.delete(hash);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.querySelector(`[data-selectable-id="${hash}"]`);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
selecto.selectoUnSelected.current.delete(hash);
|
||||
selecto.selecto.current.setSelectedTargets([
|
||||
...selecto.selecto.current.getSelectedTargets(),
|
||||
element as HTMLElement
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecto) return;
|
||||
|
||||
return () => {
|
||||
const element = document.querySelector(`[data-selectable-id="${hash}"]`);
|
||||
if (selected && !element) selecto.selectoUnSelected.current.add(hash);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
data-selectable=""
|
||||
data-selectable-index={props.index}
|
||||
data-selectable-id={hash}
|
||||
onMouseDown={props.onMouseDown}
|
||||
onContextMenu={(e) => {
|
||||
if (explorerView.selectable && !explorer.selectedItems.has(props.item)) {
|
||||
explorer.resetSelectedItems([props.item]);
|
||||
selecto?.selecto.current?.setSelectedTargets([e.currentTarget]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children({ item: props.item, selected, cut })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CHROME_REGEX = /Chrome/;
|
||||
|
||||
export default ({ children }: { children: RenderItem }) => {
|
||||
const os = useOperatingSystem();
|
||||
|
||||
const isChrome = CHROME_REGEX.test(navigator.userAgent);
|
||||
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
const selecto = useRef<Selecto>(null);
|
||||
const selectoUnSelected = useRef<Set<ExplorerItemHash>>(new Set());
|
||||
const selectoFirstColumn = useRef<number | undefined>();
|
||||
const selectoLastColumn = useRef<number | undefined>();
|
||||
|
||||
const [dragFromThumbnail, setDragFromThumbnail] = useState(false);
|
||||
|
||||
const itemDetailsHeight =
|
||||
explorerStore.gridItemSize / 4 + (explorerStore.showBytesInGridView ? 20 : 0);
|
||||
const itemHeight = explorerStore.gridItemSize + itemDetailsHeight;
|
||||
|
||||
const grid = useGridList({
|
||||
ref: explorerView.ref,
|
||||
count: explorer.items?.length ?? 0,
|
||||
overscan: explorer.overscan,
|
||||
onLoadMore: explorer.loadMore,
|
||||
rowsBeforeLoadMore: explorer.rowsBeforeLoadMore,
|
||||
size:
|
||||
explorerStore.layoutMode === 'grid'
|
||||
? { width: explorerStore.gridItemSize, height: itemHeight }
|
||||
: undefined,
|
||||
columns: explorerStore.layoutMode === 'media' ? explorerStore.mediaColumns : undefined,
|
||||
getItemId: (index) => {
|
||||
const item = explorer.items?.[index];
|
||||
return item ? explorerItemHash(item) : undefined;
|
||||
},
|
||||
getItemData: (index) => explorer.items?.[index],
|
||||
padding: explorerView.padding || explorerStore.layoutMode === 'grid' ? 12 : undefined,
|
||||
gap:
|
||||
explorerView.gap ||
|
||||
(explorerStore.layoutMode === 'grid' ? explorerStore.gridGap : undefined),
|
||||
top: explorerView.top
|
||||
});
|
||||
|
||||
function getElementId(element: Element) {
|
||||
return element.getAttribute('data-selectable-id') as ExplorerItemHash | null;
|
||||
}
|
||||
|
||||
function getElementIndex(element: Element) {
|
||||
const index = element.getAttribute('data-selectable-index');
|
||||
return index ? Number(index) : null;
|
||||
}
|
||||
|
||||
function getElementItem(element: Element) {
|
||||
const index = getElementIndex(element);
|
||||
if (index === null) return null;
|
||||
|
||||
return grid.getItem(index) ?? null;
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const element = explorer.scrollRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
selecto.current?.checkScroll();
|
||||
selecto.current?.findSelectableTargets();
|
||||
};
|
||||
|
||||
element.addEventListener('scroll', handleScroll);
|
||||
return () => element.removeEventListener('scroll', handleScroll);
|
||||
},
|
||||
// explorer.scrollRef is a stable reference so this only actually runs once
|
||||
[explorer.scrollRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecto.current) return;
|
||||
|
||||
const set = new Set(explorer.selectedItemHashes.value);
|
||||
if (set.size === 0) return;
|
||||
|
||||
const items = [...document.querySelectorAll('[data-selectable]')].filter((item) => {
|
||||
const id = getElementId(item);
|
||||
if (id === null) return;
|
||||
|
||||
const selected = set.has(id);
|
||||
if (selected) set.delete(id);
|
||||
|
||||
return selected;
|
||||
});
|
||||
|
||||
selectoUnSelected.current = set;
|
||||
selecto.current.setSelectedTargets(items as HTMLElement[]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [grid.columnCount, explorer.items]);
|
||||
|
||||
// The item that further selection will move from (shift + arrow for example).
|
||||
// This used to be calculated from the last item of selectedItems,
|
||||
// but Set ordering isn't reliable.
|
||||
// Ref bc we never actually render this.
|
||||
const activeItem = useRef<ExplorerItem | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (explorer.selectedItems.size !== 0) return;
|
||||
|
||||
selectoUnSelected.current = new Set();
|
||||
// Accessing refs during render is bad
|
||||
activeItem.current = null;
|
||||
}, [explorer.selectedItems]);
|
||||
|
||||
useKey(['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'], (e) => {
|
||||
if (explorer.selectedItems.size > 0) e.preventDefault();
|
||||
|
||||
if (!explorerView.selectable) return;
|
||||
|
||||
const lastItem = activeItem.current;
|
||||
if (!lastItem) return;
|
||||
|
||||
const lastItemIndex = explorer.items?.findIndex((item) => item === lastItem);
|
||||
if (lastItemIndex === undefined || lastItemIndex === -1) return;
|
||||
|
||||
const gridItem = grid.getItem(lastItemIndex);
|
||||
if (!gridItem) return;
|
||||
|
||||
const currentIndex = gridItem.index;
|
||||
let newIndex = currentIndex;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
newIndex -= grid.columnCount;
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
newIndex += grid.columnCount;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (grid.columnCount === (currentIndex % grid.columnCount) + 1) return;
|
||||
newIndex += 1;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (currentIndex % grid.columnCount === 0) return;
|
||||
newIndex -= 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const newSelectedItem = grid.getItem(newIndex);
|
||||
if (!newSelectedItem?.data) return;
|
||||
|
||||
if (!explorer.allowMultiSelect) explorer.resetSelectedItems([newSelectedItem.data]);
|
||||
else {
|
||||
const selectedItemDom = document.querySelector(
|
||||
`[data-selectable-id="${explorerItemHash(newSelectedItem.data)}"]`
|
||||
);
|
||||
|
||||
if (!selectedItemDom) return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (!explorer.selectedItems.has(newSelectedItem.data)) {
|
||||
explorer.addSelectedItem(newSelectedItem.data);
|
||||
selecto.current?.setSelectedTargets([
|
||||
...(selecto.current?.getSelectedTargets() || []),
|
||||
selectedItemDom as HTMLElement
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
explorer.resetSelectedItems([newSelectedItem.data]);
|
||||
selecto.current?.setSelectedTargets([selectedItemDom as HTMLElement]);
|
||||
if (selectoUnSelected.current.size > 0) selectoUnSelected.current = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
activeItem.current = newSelectedItem.data;
|
||||
|
||||
if (
|
||||
explorer.scrollRef.current &&
|
||||
explorerView.ref.current &&
|
||||
(e.key === 'ArrowUp' || e.key === 'ArrowDown')
|
||||
) {
|
||||
const paddingTop = parseInt(getComputedStyle(explorer.scrollRef.current).paddingTop);
|
||||
|
||||
const viewRect = explorerView.ref.current.getBoundingClientRect();
|
||||
|
||||
const itemRect = newSelectedItem.rect;
|
||||
const itemTop = itemRect.top + viewRect.top;
|
||||
const itemBottom = itemRect.bottom + viewRect.top;
|
||||
|
||||
const scrollRect = explorer.scrollRef.current.getBoundingClientRect();
|
||||
const scrollTop = paddingTop + (explorerView.top || 0) + 1;
|
||||
const scrollBottom = scrollRect.height - (os !== 'windows' && os !== 'browser' ? 2 : 1);
|
||||
|
||||
if (itemTop < scrollTop) {
|
||||
explorer.scrollRef.current.scrollBy({
|
||||
top:
|
||||
itemTop -
|
||||
scrollTop -
|
||||
(newSelectedItem.row === 0 ? grid.padding.y : 0) -
|
||||
(newSelectedItem.row !== 0 ? grid.gap.y / 2 : 0),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (itemBottom > scrollBottom) {
|
||||
explorer.scrollRef.current.scrollBy({
|
||||
top:
|
||||
itemBottom -
|
||||
scrollBottom +
|
||||
(newSelectedItem.row === grid.rowCount - 1 ? grid.padding.y : 0) +
|
||||
(newSelectedItem.row !== grid.rowCount - 1 ? grid.gap.y / 2 : 0),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectoContext.Provider value={selecto.current ? { selecto, selectoUnSelected } : null}>
|
||||
{explorer.allowMultiSelect && (
|
||||
<Selecto
|
||||
ref={selecto}
|
||||
boundContainer={
|
||||
explorerView.ref.current
|
||||
? {
|
||||
element: explorerView.ref.current,
|
||||
top: false,
|
||||
bottom: false
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
selectableTargets={['[data-selectable]']}
|
||||
toggleContinueSelect="shift"
|
||||
hitRate={0}
|
||||
// selectFromInside={explorerStore.layoutMode === 'media'}
|
||||
onDragStart={(e) => {
|
||||
getExplorerStore().isDragging = true;
|
||||
if ((e.inputEvent as MouseEvent).target instanceof HTMLImageElement) {
|
||||
setDragFromThumbnail(true);
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
getExplorerStore().isDragging = false;
|
||||
selectoFirstColumn.current = undefined;
|
||||
selectoLastColumn.current = undefined;
|
||||
setDragFromThumbnail(false);
|
||||
|
||||
const allSelected = selecto.current?.getSelectedTargets() ?? [];
|
||||
|
||||
// Sets active item to selected item with least index.
|
||||
// Might seem kinda weird but it's the same behaviour as Finder.
|
||||
activeItem.current =
|
||||
allSelected.reduce((least, current) => {
|
||||
const currentItem = getElementItem(current);
|
||||
if (!currentItem) return least;
|
||||
|
||||
if (!least) return currentItem;
|
||||
|
||||
return currentItem.index < least.index ? currentItem : least;
|
||||
}, null as ReturnType<typeof getElementItem>)?.data ?? null;
|
||||
}}
|
||||
onScroll={({ direction }) => {
|
||||
selecto.current?.findSelectableTargets();
|
||||
explorer.scrollRef.current?.scrollBy(
|
||||
(direction[0] || 0) * 10,
|
||||
(direction[1] || 0) * 10
|
||||
);
|
||||
}}
|
||||
scrollOptions={{
|
||||
container: { current: explorer.scrollRef.current },
|
||||
throttleTime: isChrome || dragFromThumbnail ? 30 : 10000
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
const inputEvent = e.inputEvent as MouseEvent;
|
||||
|
||||
if (inputEvent.type === 'mousedown') {
|
||||
const el = inputEvent.shiftKey
|
||||
? e.added[0] || e.removed[0]
|
||||
: e.selected[0];
|
||||
|
||||
if (!el) return;
|
||||
|
||||
const item = getElementItem(el);
|
||||
|
||||
if (!item?.data) return;
|
||||
|
||||
if (!inputEvent.shiftKey) {
|
||||
if (explorer.selectedItems.has(item.data)) {
|
||||
selecto.current?.setSelectedTargets(e.beforeSelected);
|
||||
} else {
|
||||
selectoUnSelected.current = new Set();
|
||||
explorer.resetSelectedItems([item.data]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.added[0]) explorer.addSelectedItem(item.data);
|
||||
else explorer.removeSelectedItem(item.data);
|
||||
} else if (inputEvent.type === 'mousemove') {
|
||||
const unselectedItems: ExplorerItemHash[] = [];
|
||||
|
||||
e.added.forEach((el) => {
|
||||
const item = getElementItem(el);
|
||||
|
||||
if (!item?.data) return;
|
||||
|
||||
explorer.addSelectedItem(item.data);
|
||||
});
|
||||
|
||||
e.removed.forEach((el) => {
|
||||
const item = getElementItem(el);
|
||||
|
||||
if (!item?.data || typeof item.id === 'number') return;
|
||||
|
||||
if (document.contains(el)) explorer.removeSelectedItem(item.data);
|
||||
else unselectedItems.push(item.id);
|
||||
});
|
||||
|
||||
const dragDirection = {
|
||||
x: inputEvent.x === e.rect.left ? 'left' : 'right',
|
||||
y: inputEvent.y === e.rect.bottom ? 'down' : 'up'
|
||||
} as const;
|
||||
|
||||
const dragStart = {
|
||||
x: dragDirection.x === 'right' ? e.rect.left : e.rect.right,
|
||||
y: dragDirection.y === 'down' ? e.rect.top : e.rect.bottom
|
||||
};
|
||||
|
||||
const dragEnd = { x: inputEvent.x, y: inputEvent.y };
|
||||
|
||||
const columns = new Set<number>();
|
||||
|
||||
const elements = [...e.added, ...e.removed];
|
||||
|
||||
const items = elements.reduce((items, el) => {
|
||||
const item = getElementItem(el);
|
||||
|
||||
if (!item) return items;
|
||||
|
||||
columns.add(item.column);
|
||||
return [...items, item];
|
||||
}, [] as NonNullable<ReturnType<typeof getElementItem>>[]);
|
||||
|
||||
if (columns.size > 1) {
|
||||
items.sort((a, b) => a.column - b.column);
|
||||
|
||||
const firstItem =
|
||||
dragDirection.x === 'right'
|
||||
? items[0]
|
||||
: items[items.length - 1];
|
||||
|
||||
const lastItem =
|
||||
dragDirection.x === 'right'
|
||||
? items[items.length - 1]
|
||||
: items[0];
|
||||
|
||||
if (firstItem && lastItem) {
|
||||
selectoFirstColumn.current = firstItem.column;
|
||||
selectoLastColumn.current = lastItem.column;
|
||||
}
|
||||
} else if (columns.size === 1) {
|
||||
const column = [...columns.values()][0]!;
|
||||
|
||||
items.sort((a, b) => a.row - b.row);
|
||||
|
||||
const itemRect = elements[0]?.getBoundingClientRect();
|
||||
|
||||
const inDragArea =
|
||||
itemRect &&
|
||||
(dragDirection.x === 'right'
|
||||
? dragEnd.x >= itemRect.left
|
||||
: dragEnd.x <= itemRect.right);
|
||||
|
||||
if (
|
||||
column !== selectoLastColumn.current ||
|
||||
(column === selectoLastColumn.current && !inDragArea)
|
||||
) {
|
||||
const firstItem =
|
||||
dragDirection.y === 'down'
|
||||
? items[0]
|
||||
: items[items.length - 1];
|
||||
|
||||
if (firstItem) {
|
||||
const viewRectTop =
|
||||
explorerView.ref.current?.getBoundingClientRect().top ??
|
||||
0;
|
||||
|
||||
const itemTop = firstItem.rect.top + viewRectTop;
|
||||
const itemBottom = firstItem.rect.bottom + viewRectTop;
|
||||
|
||||
if (
|
||||
dragDirection.y === 'down'
|
||||
? dragStart.y < itemTop
|
||||
: dragStart.y > itemBottom
|
||||
) {
|
||||
const dragHeight = Math.abs(
|
||||
dragStart.y -
|
||||
(dragDirection.y === 'down'
|
||||
? itemTop
|
||||
: itemBottom)
|
||||
);
|
||||
|
||||
let itemsInDragCount =
|
||||
(dragHeight - grid.gap.y) /
|
||||
(grid.virtualItemHeight + grid.gap.y);
|
||||
|
||||
if (itemsInDragCount > 1) {
|
||||
itemsInDragCount = Math.ceil(itemsInDragCount);
|
||||
} else {
|
||||
itemsInDragCount = Math.round(itemsInDragCount);
|
||||
}
|
||||
|
||||
[...Array(itemsInDragCount)].forEach((_, i) => {
|
||||
const index =
|
||||
dragDirection.y === 'down'
|
||||
? itemsInDragCount - i
|
||||
: i + 1;
|
||||
|
||||
const itemIndex =
|
||||
firstItem.index +
|
||||
(dragDirection.y === 'down' ? -index : index) *
|
||||
grid.columnCount;
|
||||
|
||||
const item = explorer.items?.[itemIndex];
|
||||
|
||||
if (item) {
|
||||
if (inputEvent.shiftKey) {
|
||||
if (explorer.selectedItems.has(item))
|
||||
explorer.removeSelectedItem(item);
|
||||
else {
|
||||
explorer.addSelectedItem(item);
|
||||
if (inDragArea)
|
||||
unselectedItems.push(
|
||||
explorerItemHash(item)
|
||||
);
|
||||
}
|
||||
} else if (!inDragArea)
|
||||
explorer.removeSelectedItem(item);
|
||||
else {
|
||||
explorer.addSelectedItem(item);
|
||||
if (inDragArea)
|
||||
unselectedItems.push(
|
||||
explorerItemHash(item)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!inDragArea && column === selectoFirstColumn.current) {
|
||||
selectoFirstColumn.current = undefined;
|
||||
selectoLastColumn.current = undefined;
|
||||
} else {
|
||||
selectoLastColumn.current = column;
|
||||
if (selectoFirstColumn.current === undefined) {
|
||||
selectoFirstColumn.current = column;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unselectedItems.length > 0) {
|
||||
selectoUnSelected.current = new Set([
|
||||
...selectoUnSelected.current,
|
||||
...unselectedItems
|
||||
]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GridList grid={grid} scrollRef={explorer.scrollRef}>
|
||||
{(index) => {
|
||||
const item = explorer.items?.[index];
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<GridListItem
|
||||
index={index}
|
||||
item={item}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!explorerView.selectable) return;
|
||||
|
||||
const item = grid.getItem(index);
|
||||
|
||||
if (!item?.data) return;
|
||||
|
||||
if (!explorer.allowMultiSelect) {
|
||||
explorer.resetSelectedItems([item.data]);
|
||||
} else {
|
||||
selectoFirstColumn.current = item.column;
|
||||
selectoLastColumn.current = item.column;
|
||||
}
|
||||
|
||||
activeItem.current = item.data;
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</SelectoContext.Provider>
|
||||
);
|
||||
};
|
|
@ -1,47 +1,47 @@
|
|||
import clsx from 'clsx';
|
||||
import { memo } from 'react';
|
||||
import { ExplorerItem, byteSize, getItemFilePath, getItemLocation } from '@sd/client';
|
||||
import { GridList } from '~/components';
|
||||
import { ViewItem } from '.';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { isCut, useExplorerStore } from '../store';
|
||||
import { useExplorerStore } from '../store';
|
||||
import GridList from './GridList';
|
||||
import RenamableItemText from './RenamableItemText';
|
||||
|
||||
interface GridViewItemProps {
|
||||
data: ExplorerItem;
|
||||
selected: boolean;
|
||||
index: number;
|
||||
isRenaming: boolean;
|
||||
cut: boolean;
|
||||
renamable: boolean;
|
||||
}
|
||||
|
||||
const GridViewItem = memo(({ data, selected, index, cut, ...props }: GridViewItemProps) => {
|
||||
const GridViewItem = memo(({ data, selected, cut, isRenaming, renamable }: GridViewItemProps) => {
|
||||
const filePathData = getItemFilePath(data);
|
||||
const location = getItemLocation(data);
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorerView = useExplorerViewContext();
|
||||
const { showBytesInGridView, gridItemSize } = useExplorerStore();
|
||||
|
||||
const showSize =
|
||||
!filePathData?.is_dir &&
|
||||
!location &&
|
||||
explorerStore.showBytesInGridView &&
|
||||
(!explorerView.isRenaming || (explorerView.isRenaming && !selected));
|
||||
showBytesInGridView &&
|
||||
(!isRenaming || (isRenaming && !selected));
|
||||
|
||||
return (
|
||||
<ViewItem data={data} className="h-full w-full" {...props}>
|
||||
<div className={clsx('mb-1 rounded-lg ', selected && 'bg-app-selectedItem')}>
|
||||
<FileThumb
|
||||
data={data}
|
||||
size={explorerStore.gridItemSize}
|
||||
className={clsx('mx-auto', cut && 'opacity-60')}
|
||||
/>
|
||||
<ViewItem data={data} className="h-full w-full">
|
||||
<div
|
||||
className={clsx('mb-1 aspect-square rounded-lg', selected && 'bg-app-selectedItem')}
|
||||
>
|
||||
<FileThumb data={data} frame className={clsx('px-2 py-1', cut && 'opacity-60')} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center">
|
||||
<RenamableItemText
|
||||
item={data}
|
||||
selected={selected}
|
||||
style={{ maxHeight: explorerStore.gridItemSize / 3 }}
|
||||
style={{ maxHeight: gridItemSize / 3 }}
|
||||
disabled={!renamable}
|
||||
/>
|
||||
{showSize && filePathData?.size_in_bytes_bytes && (
|
||||
<span
|
||||
|
@ -58,45 +58,20 @@ const GridViewItem = memo(({ data, selected, index, cut, ...props }: GridViewIte
|
|||
});
|
||||
|
||||
export default () => {
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorer = useExplorerContext();
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
const itemDetailsHeight =
|
||||
explorerStore.gridItemSize / 4 + (explorerStore.showBytesInGridView ? 20 : 0);
|
||||
const itemHeight = explorerStore.gridItemSize + itemDetailsHeight;
|
||||
|
||||
return (
|
||||
<GridList
|
||||
scrollRef={explorerView.scrollRef}
|
||||
count={explorerView.items?.length || 100}
|
||||
size={{ width: explorerStore.gridItemSize, height: itemHeight }}
|
||||
padding={12}
|
||||
selectable={!!explorerView.items}
|
||||
selected={explorerView.selected}
|
||||
onSelectedChange={explorerView.onSelectedChange}
|
||||
overscan={explorerView.overscan}
|
||||
onLoadMore={explorerView.onLoadMore}
|
||||
rowsBeforeLoadMore={explorerView.rowsBeforeLoadMore}
|
||||
top={explorerView.top}
|
||||
preventSelection={explorerView.isRenaming || !explorerView.selectable}
|
||||
preventContextMenuSelection={explorerView.contextMenu === undefined}
|
||||
>
|
||||
{({ index, item: Item }) => {
|
||||
const item = explorerView.items?.[index];
|
||||
if (!item) return null;
|
||||
|
||||
const isSelected = Array.isArray(explorerView.selected)
|
||||
? explorerView.selected.includes(item.item.id)
|
||||
: explorerView.selected === item.item.id;
|
||||
|
||||
const cut = isCut(item.item.id);
|
||||
|
||||
return (
|
||||
<Item selected={isSelected} id={item.item.id}>
|
||||
<GridViewItem data={item} selected={isSelected} index={index} cut={cut} />
|
||||
</Item>
|
||||
);
|
||||
}}
|
||||
<GridList>
|
||||
{({ item, selected, cut }) => (
|
||||
<GridViewItem
|
||||
data={item}
|
||||
selected={selected}
|
||||
cut={cut}
|
||||
isRenaming={explorerView.isRenaming}
|
||||
renamable={explorer.selectedItems.size === 1}
|
||||
/>
|
||||
)}
|
||||
</GridList>
|
||||
);
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -3,19 +3,18 @@ import { ArrowsOutSimple } from 'phosphor-react';
|
|||
import { memo } from 'react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import { GridList } from '~/components';
|
||||
import { ViewItem } from '.';
|
||||
import FileThumb from '../FilePath/Thumb';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
import GridList from './GridList';
|
||||
|
||||
interface MediaViewItemProps {
|
||||
data: ExplorerItem;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
cut: boolean;
|
||||
}
|
||||
|
||||
const MediaViewItem = memo(({ data, index, selected }: MediaViewItemProps) => {
|
||||
const MediaViewItem = memo(({ data, selected, cut }: MediaViewItemProps) => {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
return (
|
||||
|
@ -33,10 +32,12 @@ const MediaViewItem = memo(({ data, index, selected }: MediaViewItemProps) => {
|
|||
)}
|
||||
>
|
||||
<FileThumb
|
||||
size={0}
|
||||
data={data}
|
||||
cover={explorerStore.mediaAspectSquare}
|
||||
className="!rounded-none"
|
||||
className={clsx(
|
||||
!explorerStore.mediaAspectSquare && 'px-1',
|
||||
cut && 'opacity-60'
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
@ -53,45 +54,11 @@ const MediaViewItem = memo(({ data, index, selected }: MediaViewItemProps) => {
|
|||
});
|
||||
|
||||
export default () => {
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
return (
|
||||
<GridList
|
||||
scrollRef={explorerView.scrollRef}
|
||||
count={explorerView.items?.length || 100}
|
||||
columns={explorerStore.mediaColumns}
|
||||
selected={explorerView.selected}
|
||||
onSelectedChange={explorerView.onSelectedChange}
|
||||
overscan={explorerView.overscan}
|
||||
onLoadMore={explorerView.onLoadMore}
|
||||
rowsBeforeLoadMore={explorerView.rowsBeforeLoadMore}
|
||||
top={explorerView.top}
|
||||
preventSelection={!explorerView.selectable}
|
||||
preventContextMenuSelection={explorerView.contextMenu === undefined}
|
||||
>
|
||||
{({ index, item: Item }) => {
|
||||
if (!explorerView.items) {
|
||||
return (
|
||||
<Item className="!p-px">
|
||||
<div className="h-full animate-pulse bg-app-box" />
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
const item = explorerView.items[index];
|
||||
if (!item) return null;
|
||||
|
||||
const isSelected = Array.isArray(explorerView.selected)
|
||||
? explorerView.selected.includes(item.item.id)
|
||||
: explorerView.selected === item.item.id;
|
||||
|
||||
return (
|
||||
<Item selectable selected={isSelected} index={index} id={item.item.id}>
|
||||
<MediaViewItem data={item} index={index} selected={isSelected} />
|
||||
</Item>
|
||||
);
|
||||
}}
|
||||
<GridList>
|
||||
{({ item, selected, cut }) => (
|
||||
<MediaViewItem data={item} selected={selected} cut={cut} />
|
||||
)}
|
||||
</GridList>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,16 +8,19 @@ import {
|
|||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ExplorerItem,
|
||||
getExplorerItemData,
|
||||
FilePath,
|
||||
Location,
|
||||
Object,
|
||||
getItemFilePath,
|
||||
getItemLocation,
|
||||
getItemObject,
|
||||
isPath,
|
||||
useLibraryContext,
|
||||
useLibraryMutation
|
||||
|
@ -25,17 +28,13 @@ import {
|
|||
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import CreateDialog from '../../settings/library/tags/CreateDialog';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { QuickPreview } from '../QuickPreview';
|
||||
import { useQuickPreviewContext } from '../QuickPreview/Context';
|
||||
import {
|
||||
ExplorerViewContext,
|
||||
ExplorerViewSelection,
|
||||
ExplorerViewSelectionChange,
|
||||
ViewContext,
|
||||
useExplorerViewContext
|
||||
} from '../ViewContext';
|
||||
import { ExplorerViewContext, ViewContext, useExplorerViewContext } from '../ViewContext';
|
||||
import { useExplorerConfigStore } from '../config';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
import GridView from './GridView';
|
||||
|
@ -47,49 +46,98 @@ interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement
|
|||
}
|
||||
|
||||
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
|
||||
const explorer = useExplorerContext();
|
||||
const explorerView = useExplorerViewContext();
|
||||
const { library } = useLibraryContext();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { openFilePaths } = usePlatform();
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
const filePath = getItemFilePath(data);
|
||||
const location = getItemLocation(data);
|
||||
|
||||
const explorerConfig = useExplorerConfigStore();
|
||||
|
||||
const onDoubleClick = () => {
|
||||
if (location) {
|
||||
const navigate = useNavigate();
|
||||
const { library } = useLibraryContext();
|
||||
const { openFilePaths } = usePlatform();
|
||||
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
|
||||
function updateList<T = FilePath | Location>(list: T[], item: T, push: boolean) {
|
||||
return !push ? [item, ...list] : [...list, item];
|
||||
}
|
||||
|
||||
const onDoubleClick = async () => {
|
||||
const selectedItems = [...explorer.selectedItems].reduce(
|
||||
(items, item) => {
|
||||
const sameAsClicked = data.item.id === item.item.id;
|
||||
|
||||
switch (item.type) {
|
||||
case 'Path':
|
||||
case 'Object': {
|
||||
const filePath = getItemFilePath(item);
|
||||
if (filePath) {
|
||||
if (isPath(item) && item.item.is_dir) {
|
||||
items.dirs = updateList(items.dirs, filePath, !sameAsClicked);
|
||||
} else items.paths = updateList(items.paths, filePath, !sameAsClicked);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Location': {
|
||||
items.locations = updateList(items.locations, item.item, !sameAsClicked);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
{
|
||||
paths: [],
|
||||
dirs: [],
|
||||
locations: []
|
||||
} as { paths: FilePath[]; dirs: FilePath[]; locations: Location[] }
|
||||
);
|
||||
|
||||
if (selectedItems.paths.length > 0 && !explorerView.isRenaming) {
|
||||
if (explorerConfig.openOnDoubleClick && openFilePaths) {
|
||||
updateAccessTime
|
||||
.mutateAsync(
|
||||
selectedItems.paths.map(({ object_id }) => object_id!).filter(Boolean)
|
||||
)
|
||||
.catch(console.error);
|
||||
|
||||
try {
|
||||
await openFilePaths(
|
||||
library.uuid,
|
||||
selectedItems.paths.map(({ id }) => id)
|
||||
);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
value: `Failed to open file, due to an error: ${error}`
|
||||
});
|
||||
}
|
||||
} else if (!explorerConfig.openOnDoubleClick) {
|
||||
if (data.type !== 'Location' && !(isPath(data) && data.item.is_dir)) {
|
||||
getExplorerStore().quickViewObject = data;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedItems.dirs.length > 0) {
|
||||
const item = selectedItems.dirs[0];
|
||||
if (!item) return;
|
||||
|
||||
navigate({
|
||||
pathname: `/${library.uuid}/location/${location.id}`,
|
||||
pathname: `../location/${item.location_id}`,
|
||||
search: createSearchParams({
|
||||
path: `${item.materialized_path}${item.name}/`
|
||||
}).toString()
|
||||
});
|
||||
} else if (selectedItems.locations.length > 0) {
|
||||
const location = selectedItems.locations[0];
|
||||
if (!location) return;
|
||||
|
||||
navigate({
|
||||
pathname: `../location/${location.id}`,
|
||||
search: createSearchParams({
|
||||
path: `/`
|
||||
}).toString()
|
||||
});
|
||||
} else if (isPath(data) && data.item.is_dir) {
|
||||
navigate({
|
||||
pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`,
|
||||
search: createSearchParams({
|
||||
path: `${data.item.materialized_path}${data.item.name}/`
|
||||
}).toString()
|
||||
});
|
||||
} else if (
|
||||
openFilePaths &&
|
||||
filePath &&
|
||||
explorerConfig.openOnDoubleClick &&
|
||||
!explorerView.isRenaming
|
||||
) {
|
||||
if (data.type === 'Path' && data.item.object_id) {
|
||||
updateAccessTime.mutate(data.item.object_id);
|
||||
}
|
||||
|
||||
openFilePaths(library.uuid, [filePath.id]);
|
||||
} else {
|
||||
const { kind } = getExplorerItemData(data);
|
||||
|
||||
if (['Video', 'Image', 'Audio'].includes(kind)) {
|
||||
getExplorerStore().quickViewObject = data;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -103,63 +151,62 @@ export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
|
|||
onOpenChange={explorerView.setIsContextMenuOpen}
|
||||
disabled={explorerView.contextMenu === undefined}
|
||||
asChild={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{explorerView.contextMenu}
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ExplorerViewProps<T extends ExplorerViewSelection = ExplorerViewSelection>
|
||||
export interface ExplorerViewProps
|
||||
extends Omit<
|
||||
ExplorerViewContext<T>,
|
||||
'multiSelect' | 'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen'
|
||||
ExplorerViewContext,
|
||||
'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen' | 'ref'
|
||||
> {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
emptyNotice?: JSX.Element;
|
||||
}
|
||||
|
||||
export default memo(
|
||||
<T extends ExplorerViewSelection>({
|
||||
className,
|
||||
emptyNotice,
|
||||
...contextProps
|
||||
}: ExplorerViewProps<T>) => {
|
||||
export default memo(({ className, style, emptyNotice, ...contextProps }: ExplorerViewProps) => {
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const quickPreviewCtx = useQuickPreviewContext();
|
||||
|
||||
const { layoutMode } = useExplorerStore();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
|
||||
useKeyDownHandlers({
|
||||
items: contextProps.items,
|
||||
selected: contextProps.selected,
|
||||
isRenaming
|
||||
});
|
||||
|
||||
const quickPreviewCtx = useQuickPreviewContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={clsx('h-full w-full', className)}
|
||||
onMouseDown={() =>
|
||||
contextProps.onSelectedChange?.(
|
||||
(Array.isArray(contextProps.selected)
|
||||
? []
|
||||
: undefined) as ExplorerViewSelectionChange<T>
|
||||
)
|
||||
}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2 || (e.button === 0 && e.shiftKey)) return;
|
||||
|
||||
explorer.resetSelectedItems();
|
||||
}}
|
||||
>
|
||||
{contextProps.items === null ||
|
||||
(contextProps.items && contextProps.items.length > 0) ? (
|
||||
{explorer.items === null || (explorer.items && explorer.items.length > 0) ? (
|
||||
<ViewContext.Provider
|
||||
value={
|
||||
{
|
||||
...contextProps,
|
||||
multiSelect: Array.isArray(contextProps.selected),
|
||||
selectable: !isContextMenuOpen,
|
||||
selectable:
|
||||
explorer.selectable && !isContextMenuOpen && !isRenaming,
|
||||
setIsContextMenuOpen,
|
||||
isRenaming,
|
||||
setIsRenaming
|
||||
setIsRenaming,
|
||||
ref
|
||||
} as ExplorerViewContext
|
||||
}
|
||||
>
|
||||
|
@ -171,20 +218,12 @@ export default memo(
|
|||
emptyNotice
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quickPreviewCtx.ref && createPortal(<QuickPreview />, quickPreviewCtx.ref)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
) as <T extends ExplorerViewSelection>(props: ExplorerViewProps<T>) => JSX.Element;
|
||||
});
|
||||
|
||||
export const EmptyNotice = ({
|
||||
icon,
|
||||
message
|
||||
}: {
|
||||
icon?: Icon | ReactNode;
|
||||
message?: ReactNode;
|
||||
}) => {
|
||||
export const EmptyNotice = (props: { icon?: Icon | ReactNode; message?: ReactNode }) => {
|
||||
const { layoutMode } = useExplorerStore();
|
||||
|
||||
const emptyNoticeIcon = (icon?: Icon) => {
|
||||
|
@ -202,64 +241,71 @@ export const EmptyNotice = ({
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
|
||||
{icon
|
||||
? isValidElement(icon)
|
||||
? icon
|
||||
: emptyNoticeIcon(icon as Icon)
|
||||
{props.icon
|
||||
? isValidElement(props.icon)
|
||||
? props.icon
|
||||
: emptyNoticeIcon(props.icon as Icon)
|
||||
: emptyNoticeIcon()}
|
||||
|
||||
<p className="mt-5 text-sm font-medium">
|
||||
{message !== undefined ? message : 'This list is empty'}
|
||||
{props.message !== undefined ? props.message : 'This list is empty'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useKeyDownHandlers = ({
|
||||
items,
|
||||
selected,
|
||||
isRenaming
|
||||
}: Pick<ExplorerViewProps, 'items' | 'selected'> & { isRenaming: boolean }) => {
|
||||
const useKeyDownHandlers = ({ isRenaming }: { isRenaming: boolean }) => {
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const os = useOperatingSystem();
|
||||
const { library } = useLibraryContext();
|
||||
const { openFilePaths } = usePlatform();
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() =>
|
||||
items?.find(
|
||||
(item) => item.item.id === (Array.isArray(selected) ? selected[0] : selected)
|
||||
),
|
||||
[items, selected]
|
||||
);
|
||||
|
||||
const itemPath = selectedItem ? getItemFilePath(selectedItem) : null;
|
||||
|
||||
const handleNewTag = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
const objects: Object[] = [];
|
||||
|
||||
for (const item of explorer.selectedItems) {
|
||||
const object = getItemObject(item);
|
||||
if (!object) return;
|
||||
objects.push(object);
|
||||
}
|
||||
|
||||
if (
|
||||
itemPath == null ||
|
||||
!isNonEmpty(objects) ||
|
||||
event.key.toUpperCase() !== 'N' ||
|
||||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
|
||||
)
|
||||
return;
|
||||
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} assignToObject={itemPath.id} />);
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} objects={objects} />);
|
||||
},
|
||||
[os, itemPath]
|
||||
[os, explorer.selectedItems]
|
||||
);
|
||||
|
||||
const handleOpenShortcut = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (
|
||||
itemPath == null ||
|
||||
openFilePaths == null ||
|
||||
event.key.toUpperCase() !== 'O' ||
|
||||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
|
||||
event.code.toUpperCase() !== 'O' ||
|
||||
!event.getModifierState(
|
||||
os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control
|
||||
) ||
|
||||
!openFilePaths
|
||||
)
|
||||
return;
|
||||
|
||||
const paths: number[] = [];
|
||||
|
||||
for (const item of explorer.selectedItems) {
|
||||
const path = getItemFilePath(item);
|
||||
if (!path) return;
|
||||
paths.push(path.id);
|
||||
}
|
||||
|
||||
if (!isNonEmpty(paths)) return;
|
||||
|
||||
try {
|
||||
await openFilePaths(library.uuid, [itemPath.id]);
|
||||
await openFilePaths(library.uuid, paths);
|
||||
} catch (error) {
|
||||
showAlertDialog({
|
||||
title: 'Error',
|
||||
|
@ -267,21 +313,23 @@ const useKeyDownHandlers = ({
|
|||
});
|
||||
}
|
||||
},
|
||||
[os, itemPath, library.uuid, openFilePaths]
|
||||
[os, library.uuid, openFilePaths, explorer.selectedItems]
|
||||
);
|
||||
|
||||
const handleOpenQuickPreview = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (event.key !== ' ') return;
|
||||
if (!getExplorerStore().quickViewObject) {
|
||||
if (selectedItem) {
|
||||
getExplorerStore().quickViewObject = selectedItem;
|
||||
}
|
||||
// ENG-973 - Don't use Set -> Array -> First Item
|
||||
const items = [...explorer.selectedItems];
|
||||
if (!isNonEmpty(items)) return;
|
||||
|
||||
getExplorerStore().quickViewObject = items[0];
|
||||
} else {
|
||||
getExplorerStore().quickViewObject = null;
|
||||
}
|
||||
},
|
||||
[selectedItem]
|
||||
[explorer.selectedItems]
|
||||
);
|
||||
|
||||
const handleExplorerShortcut = useCallback(
|
||||
|
|
|
@ -1,30 +1,19 @@
|
|||
import { ReactNode, RefObject, createContext, useContext } from 'react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
|
||||
export type ExplorerViewSelection = number | number[];
|
||||
export type ExplorerViewSelection = number | Set<number>;
|
||||
|
||||
export interface ExplorerViewContext<T extends ExplorerViewSelection = ExplorerViewSelection> {
|
||||
items: ExplorerItem[] | null;
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
selected?: T;
|
||||
onSelectedChange?: (selected: ExplorerViewSelectionChange<T>) => void;
|
||||
overscan?: number;
|
||||
onLoadMore?: () => void;
|
||||
rowsBeforeLoadMore?: number;
|
||||
export interface ExplorerViewContext {
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
top?: number;
|
||||
multiSelect?: boolean;
|
||||
contextMenu?: ReactNode;
|
||||
setIsContextMenuOpen?: (isOpen: boolean) => void;
|
||||
isRenaming: boolean;
|
||||
setIsRenaming: (isRenaming: boolean) => void;
|
||||
selectable?: boolean;
|
||||
padding?: number | { x?: number; y?: number };
|
||||
gap?: number | { x?: number; y?: number };
|
||||
selectable: boolean;
|
||||
}
|
||||
|
||||
export type ExplorerViewSelectionChange<T extends ExplorerViewSelection> = T extends number[]
|
||||
? number[]
|
||||
: number | undefined;
|
||||
|
||||
export const ViewContext = createContext<ExplorerViewContext | null>(null);
|
||||
|
||||
export const useExplorerViewContext = () => {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { FolderNotchOpen } from 'phosphor-react';
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ExplorerItem, useLibrarySubscription } from '@sd/client';
|
||||
import { useKeyDeleteFile } from '~/hooks';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import { useLibrarySubscription } from '@sd/client';
|
||||
import { TOP_BAR_HEIGHT } from '../TopBar';
|
||||
import { useExplorerContext } from './Context';
|
||||
import ContextMenu from './ContextMenu';
|
||||
|
@ -10,34 +9,23 @@ import { Inspector } from './Inspector';
|
|||
import ExplorerContextMenu from './ParentContextMenu';
|
||||
import View, { EmptyNotice, ExplorerViewProps } from './View';
|
||||
import { useExplorerStore } from './store';
|
||||
import { useExplorerSearchParams } from './util';
|
||||
|
||||
interface Props {
|
||||
items: ExplorerItem[] | null;
|
||||
onLoadMore?(): void;
|
||||
emptyNotice?: ExplorerViewProps['emptyNotice'];
|
||||
contextMenu?: (item: ExplorerItem) => ReactNode;
|
||||
contextMenu?: () => ReactNode;
|
||||
}
|
||||
|
||||
export default function Explorer(props: Props) {
|
||||
const INSPECTOR_WIDTH = 260;
|
||||
const INSPECTOR_WIDTH = 260;
|
||||
|
||||
/**
|
||||
* This component is used in a few routes and acts as the reference demonstration of how to combine
|
||||
* all the elements of the explorer except for the context, which must be used in the parent component.
|
||||
*/
|
||||
export default function Explorer(props: PropsWithChildren<Props>) {
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedItemId, setSelectedItemId] = useState<number>();
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() =>
|
||||
selectedItemId
|
||||
? props.items?.find((item) => item.item.id === selectedItemId)
|
||||
: undefined,
|
||||
[selectedItemId, props.items]
|
||||
);
|
||||
|
||||
// Can we put this somewhere else -_-
|
||||
useLibrarySubscription(['jobs.newThumbnail'], {
|
||||
onStarted: () => {
|
||||
console.log('Started RSPC subscription new thumbnail');
|
||||
|
@ -50,47 +38,24 @@ export default function Explorer(props: Props) {
|
|||
}
|
||||
});
|
||||
|
||||
const ctx = useExplorerContext();
|
||||
|
||||
useKeyDeleteFile(
|
||||
selectedItem || null,
|
||||
ctx.parent?.type === 'Location' ? ctx.parent.location.id : null
|
||||
);
|
||||
|
||||
useEffect(() => setSelectedItemId(undefined), [path]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExplorerContextMenu>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
ref={explorer.scrollRef}
|
||||
className="custom-scroll explorer-scroll relative h-screen overflow-x-hidden"
|
||||
style={{
|
||||
paddingTop: TOP_BAR_HEIGHT,
|
||||
paddingRight: explorerStore.showInspector ? INSPECTOR_WIDTH : 0
|
||||
}}
|
||||
>
|
||||
{props.items && props.items.length > 0 && <DismissibleNotice />}
|
||||
{explorer.items && explorer.items.length > 0 && <DismissibleNotice />}
|
||||
|
||||
<View
|
||||
items={props.items}
|
||||
scrollRef={scrollRef}
|
||||
onLoadMore={props.onLoadMore}
|
||||
rowsBeforeLoadMore={5}
|
||||
selected={selectedItemId}
|
||||
onSelectedChange={setSelectedItemId}
|
||||
contextMenu={
|
||||
selectedItem ? (
|
||||
props.contextMenu ? (
|
||||
props.contextMenu(selectedItem)
|
||||
) : (
|
||||
<ContextMenu item={selectedItem} />
|
||||
)
|
||||
) : null
|
||||
}
|
||||
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
|
||||
emptyNotice={
|
||||
props.emptyNotice || (
|
||||
props.emptyNotice ?? (
|
||||
<EmptyNotice
|
||||
icon={FolderNotchOpen}
|
||||
message="This folder is empty"
|
||||
|
@ -104,7 +69,6 @@ export default function Explorer(props: Props) {
|
|||
|
||||
{explorerStore.showInspector && (
|
||||
<Inspector
|
||||
data={selectedItem}
|
||||
className="custom-scroll inspector-scroll absolute inset-y-0 right-0 pb-4 pl-1.5 pr-1"
|
||||
style={{ paddingTop: TOP_BAR_HEIGHT + 16, width: INSPECTOR_WIDTH }}
|
||||
/>
|
||||
|
|
|
@ -26,6 +26,17 @@ export type CutCopyType = 'Cut' | 'Copy';
|
|||
export type FilePathSearchOrderingKeys = UnionKeys<FilePathSearchOrdering> | 'none';
|
||||
export type ObjectSearchOrderingKeys = UnionKeys<ObjectSearchOrdering> | 'none';
|
||||
|
||||
type CutCopyState =
|
||||
| {
|
||||
type: 'Idle';
|
||||
}
|
||||
| {
|
||||
type: 'Cut' | 'Copy';
|
||||
sourceParentPath: string; // this is used solely for preventing copy/cutting to the same path (as that will truncate the file)
|
||||
sourceLocationId: number;
|
||||
sourcePathIds: number[];
|
||||
};
|
||||
|
||||
const state = {
|
||||
layoutMode: 'grid' as ExplorerLayoutMode,
|
||||
gridItemSize: 110,
|
||||
|
@ -34,21 +45,16 @@ const state = {
|
|||
tagAssignMode: false,
|
||||
showInspector: false,
|
||||
mediaPlayerVolume: 0.7,
|
||||
multiSelectIndexes: [] as number[],
|
||||
newThumbnails: proxySet() as Set<string>,
|
||||
cutCopyState: {
|
||||
sourceParentPath: '', // this is used solely for preventing copy/cutting to the same path (as that will truncate the file)
|
||||
sourceLocationId: 0,
|
||||
sourcePathId: 0,
|
||||
actionType: 'Cut',
|
||||
active: false
|
||||
},
|
||||
cutCopyState: { type: 'Idle' } as CutCopyState,
|
||||
quickViewObject: null as ExplorerItem | null,
|
||||
mediaColumns: 8,
|
||||
mediaAspectSquare: false,
|
||||
orderBy: 'dateCreated' as FilePathSearchOrderingKeys,
|
||||
orderByDirection: 'Desc' as SortOrder,
|
||||
groupBy: 'none'
|
||||
groupBy: 'none',
|
||||
isDragging: false,
|
||||
gridGap: 8
|
||||
};
|
||||
|
||||
export function flattenThumbnailKey(thumbKey: string[]) {
|
||||
|
@ -78,9 +84,6 @@ export function getExplorerStore() {
|
|||
}
|
||||
|
||||
export function isCut(id: number) {
|
||||
return (
|
||||
explorerStore.cutCopyState.active &&
|
||||
explorerStore.cutCopyState.actionType === 'Cut' &&
|
||||
explorerStore.cutCopyState.sourcePathId === id
|
||||
);
|
||||
const state = explorerStore.cutCopyState;
|
||||
return state.type === 'Cut' && state.sourcePathIds.includes(id);
|
||||
}
|
||||
|
|
132
interface/app/$libraryId/Explorer/useExplorer.ts
Normal file
132
interface/app/$libraryId/Explorer/useExplorer.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ExplorerItem, FilePath, Location, NodeState, Tag } from '@sd/client';
|
||||
import { explorerItemHash } from './util';
|
||||
|
||||
export type ExplorerParent =
|
||||
| {
|
||||
type: 'Location';
|
||||
location: Location;
|
||||
subPath?: FilePath;
|
||||
}
|
||||
| {
|
||||
type: 'Tag';
|
||||
tag: Tag;
|
||||
}
|
||||
| {
|
||||
type: 'Node';
|
||||
node: NodeState;
|
||||
};
|
||||
|
||||
export interface UseExplorerProps {
|
||||
items: ExplorerItem[] | null;
|
||||
parent?: ExplorerParent;
|
||||
loadMore?: () => void;
|
||||
scrollRef?: RefObject<HTMLDivElement>;
|
||||
/**
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
allowMultiSelect?: boolean;
|
||||
/**
|
||||
* @defaultValue `5`
|
||||
*/
|
||||
rowsBeforeLoadMore?: number;
|
||||
overscan?: number;
|
||||
/**
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
selectable?: boolean;
|
||||
}
|
||||
|
||||
export type ExplorerItemMeta = {
|
||||
type: 'Location' | 'Path' | 'Object';
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type ExplorerItemHash = `${ExplorerItemMeta['type']}:${ExplorerItemMeta['id']}`;
|
||||
|
||||
/**
|
||||
* Controls top-level config and state for the explorer.
|
||||
* View- and inspector-specific state is not handled here.
|
||||
*/
|
||||
export function useExplorer(props: UseExplorerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return {
|
||||
// Default values
|
||||
allowMultiSelect: true,
|
||||
rowsBeforeLoadMore: 5,
|
||||
selectable: true,
|
||||
scrollRef,
|
||||
// Provided values
|
||||
...props,
|
||||
// Selected items
|
||||
...useSelectedItems(props.items)
|
||||
};
|
||||
}
|
||||
|
||||
export type UseExplorer = ReturnType<typeof useExplorer>;
|
||||
|
||||
function useSelectedItems(items: ExplorerItem[] | null) {
|
||||
// Doing pointer lookups for hashes is a bit faster than assembling a bunch of strings
|
||||
// WeakMap ensures that ExplorerItems aren't held onto after they're evicted from cache
|
||||
const itemHashesWeakMap = useRef(new WeakMap<ExplorerItem, ExplorerItemHash>());
|
||||
|
||||
// Store hashes of items instead as objects are unique by reference but we
|
||||
// still need to differentate between item variants
|
||||
const [selectedItemHashes, setSelectedItemHashes] = useState(() => ({
|
||||
value: new Set<ExplorerItemHash>()
|
||||
}));
|
||||
|
||||
const updateHashes = useCallback(
|
||||
() => setSelectedItemHashes((h) => ({ ...h })),
|
||||
[setSelectedItemHashes]
|
||||
);
|
||||
|
||||
const itemsMap = useMemo(
|
||||
() =>
|
||||
(items ?? []).reduce((items, item) => {
|
||||
const hash = itemHashesWeakMap.current.get(item) ?? explorerItemHash(item);
|
||||
itemHashesWeakMap.current.set(item, hash);
|
||||
items.set(hash, item);
|
||||
return items;
|
||||
}, new Map<ExplorerItemHash, ExplorerItem>()),
|
||||
[items]
|
||||
);
|
||||
|
||||
const selectedItems = useMemo(
|
||||
() =>
|
||||
[...selectedItemHashes.value].reduce((items, hash) => {
|
||||
const item = itemsMap.get(hash);
|
||||
if (item) items.add(item);
|
||||
return items;
|
||||
}, new Set<ExplorerItem>()),
|
||||
[itemsMap, selectedItemHashes]
|
||||
);
|
||||
|
||||
return {
|
||||
selectedItems,
|
||||
selectedItemHashes,
|
||||
addSelectedItem: useCallback(
|
||||
(item: ExplorerItem) => {
|
||||
selectedItemHashes.value.add(explorerItemHash(item));
|
||||
updateHashes();
|
||||
},
|
||||
[selectedItemHashes.value, updateHashes]
|
||||
),
|
||||
removeSelectedItem: useCallback(
|
||||
(item: ExplorerItem) => {
|
||||
selectedItemHashes.value.delete(explorerItemHash(item));
|
||||
updateHashes();
|
||||
},
|
||||
[selectedItemHashes.value, updateHashes]
|
||||
),
|
||||
resetSelectedItems: useCallback(
|
||||
(items?: ExplorerItem[]) => {
|
||||
selectedItemHashes.value.clear();
|
||||
items?.forEach((item) => selectedItemHashes.value.add(explorerItemHash(item)));
|
||||
updateHashes();
|
||||
},
|
||||
[selectedItemHashes.value, updateHashes]
|
||||
)
|
||||
};
|
||||
}
|
|
@ -3,6 +3,7 @@ import { ExplorerItem, FilePathSearchOrdering, getExplorerItemData } from '@sd/c
|
|||
import { ExplorerParamsSchema } from '~/app/route-schemas';
|
||||
import { useZodSearchParams } from '~/hooks';
|
||||
import { flattenThumbnailKey, useExplorerStore } from './store';
|
||||
import { ExplorerItemHash } from './useExplorer';
|
||||
|
||||
export function useExplorerOrder(): FilePathSearchOrdering | undefined {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
@ -47,3 +48,7 @@ export function useExplorerItemData(explorerItem: ExplorerItem) {
|
|||
return itemData;
|
||||
}, [explorerItem, newThumbnail]);
|
||||
}
|
||||
|
||||
export function explorerItemHash(item: ExplorerItem): ExplorerItemHash {
|
||||
return `${item.type}:${item.item.id}`;
|
||||
}
|
||||
|
|
|
@ -47,9 +47,9 @@ const Layout = () => {
|
|||
className={clsx(
|
||||
// App level styles
|
||||
'flex h-screen cursor-default select-none overflow-hidden text-ink',
|
||||
os === 'browser' && 'border-t border-app-line/50 bg-app',
|
||||
os === 'browser' && 'bg-app',
|
||||
os === 'macOS' && 'has-blur-effects rounded-[10px]',
|
||||
os !== 'browser' && os !== 'windows' && 'border border-app-frame'
|
||||
os !== 'browser' && os !== 'windows' && 'frame border border-transparent'
|
||||
)}
|
||||
onContextMenu={(e) => {
|
||||
// TODO: allow this on some UI text at least / disable default browser context menu
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import clsx from 'clsx';
|
||||
import { Ref } from 'react';
|
||||
import { useExplorerStore } from '../Explorer/store';
|
||||
import { NavigationButtons } from './NavigationButtons';
|
||||
import SearchBar from './SearchBar';
|
||||
|
||||
|
@ -9,15 +11,19 @@ interface Props {
|
|||
rightRef?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const TopBar = (props: Props) => (
|
||||
const TopBar = (props: Props) => {
|
||||
const { isDragging } = useExplorerStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="
|
||||
duration-250 top-bar-blur absolute left-0 top-0 z-50 flex
|
||||
h-[46px] w-full flex-row items-center justify-center overflow-hidden
|
||||
border-b border-sidebar-divider bg-app/90 px-3.5
|
||||
transition-[background-color,border-color] ease-out
|
||||
"
|
||||
className={clsx(
|
||||
'duration-250 top-bar-blur absolute left-0 top-0 z-50 flex',
|
||||
'h-[46px] w-full flex-row items-center justify-center overflow-hidden',
|
||||
'border-b border-sidebar-divider bg-app/90 px-3.5',
|
||||
'transition-[background-color,border-color] ease-out',
|
||||
isDragging && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div data-tauri-drag-region className="flex flex-1 flex-row items-center">
|
||||
<NavigationButtons />
|
||||
|
@ -26,6 +32,7 @@ const TopBar = (props: Props) => (
|
|||
<SearchBar />
|
||||
<div className="flex-1" ref={props.rightRef} />
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
useLibraryContext,
|
||||
useLibraryQuery,
|
||||
|
@ -8,18 +8,19 @@ import {
|
|||
} from '@sd/client';
|
||||
import { LocationIdParamsSchema } from '~/app/route-schemas';
|
||||
import { Folder } from '~/components';
|
||||
import { useZodRouteParams } from '~/hooks';
|
||||
import { useKeyDeleteFile, useZodRouteParams } from '~/hooks';
|
||||
import Explorer from '../Explorer';
|
||||
import { ExplorerContext } from '../Explorer/Context';
|
||||
import ContextMenu, { FilePathItems } from '../Explorer/ContextMenu';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
|
||||
import { useExplorer } from '../Explorer/useExplorer';
|
||||
import { useExplorerOrder, useExplorerSearchParams } from '../Explorer/util';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
import LocationOptions from './LocationOptions';
|
||||
|
||||
export const Component = () => {
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
|
||||
const location = useLibraryQuery(['locations.get', locationId]);
|
||||
|
@ -37,17 +38,27 @@ export const Component = () => {
|
|||
|
||||
const { items, loadMore } = useItems({ locationId });
|
||||
|
||||
return (
|
||||
<ExplorerContext.Provider
|
||||
value={{
|
||||
const explorer = useExplorer({
|
||||
items,
|
||||
loadMore,
|
||||
parent: location.data
|
||||
? {
|
||||
type: 'Location',
|
||||
location: location.data
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Using .call to silence eslint exhaustive deps warning.
|
||||
// If clearSelectedItems referenced 'this' then this wouldn't work
|
||||
explorer.resetSelectedItems.call(undefined);
|
||||
}, [explorer.resetSelectedItems, path]);
|
||||
|
||||
useKeyDeleteFile(explorer.selectedItems, location.data?.id);
|
||||
|
||||
return (
|
||||
<ExplorerContext.Provider value={explorer}>
|
||||
<TopBarPortal
|
||||
left={
|
||||
<div className="group flex flex-row items-center space-x-2">
|
||||
|
@ -67,25 +78,7 @@ export const Component = () => {
|
|||
right={<DefaultTopBarOptions />}
|
||||
/>
|
||||
|
||||
<Explorer
|
||||
items={items}
|
||||
onLoadMore={loadMore}
|
||||
contextMenu={(item) => (
|
||||
<ContextMenu
|
||||
item={item}
|
||||
extra={({ filePath }) => (
|
||||
<>
|
||||
{filePath && location.data && (
|
||||
<FilePathItems.CutCopyItems
|
||||
locationId={location.data.id}
|
||||
filePath={filePath}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Explorer />
|
||||
</ExplorerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
@ -130,11 +123,11 @@ const useItems = ({ locationId }: { locationId: number }) => {
|
|||
|
||||
const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) || null, [query.data]);
|
||||
|
||||
function loadMore() {
|
||||
const loadMore = useCallback(() => {
|
||||
if (query.hasNextPage && !query.isFetchingNextPage) {
|
||||
query.fetchNextPage();
|
||||
}
|
||||
query.fetchNextPage.call(undefined);
|
||||
}
|
||||
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
|
||||
|
||||
return { query, items, loadMore };
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@ import { useZodRouteParams } from '~/hooks';
|
|||
import Explorer from '../Explorer';
|
||||
import { ExplorerContext } from '../Explorer/Context';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer } from '../Explorer/useExplorer';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
||||
export const Component = () => {
|
||||
|
@ -14,17 +15,18 @@ export const Component = () => {
|
|||
|
||||
const nodeState = useBridgeQuery(['nodeState']);
|
||||
|
||||
return (
|
||||
<ExplorerContext.Provider
|
||||
value={{
|
||||
const explorer = useExplorer({
|
||||
items: query.data || null,
|
||||
parent: nodeState.data
|
||||
? {
|
||||
type: 'Node',
|
||||
node: nodeState.data
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
});
|
||||
|
||||
return (
|
||||
<ExplorerContext.Provider value={explorer}>
|
||||
<TopBarPortal
|
||||
left={
|
||||
<div className="group flex flex-row items-center space-x-2">
|
||||
|
@ -42,7 +44,7 @@ export const Component = () => {
|
|||
right={<DefaultTopBarOptions />}
|
||||
/>
|
||||
|
||||
<Explorer items={query.data || []} />
|
||||
<Explorer />
|
||||
</ExplorerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -71,7 +71,7 @@ export const Categories = (props: { selected: Category; onSelectedChanged(c: Cat
|
|||
return (
|
||||
<Sticky
|
||||
scrollElement={pageRef.current || undefined}
|
||||
stickyClassName="z-10 !top-[46px]"
|
||||
stickyClassName="z-20 !top-[46px]"
|
||||
topOffset={-46}
|
||||
>
|
||||
<div className="relative flex bg-app/90 px-3 py-1.5 backdrop-blur">
|
||||
|
|
|
@ -103,12 +103,12 @@ export function useItems(category: Category) {
|
|||
|
||||
return isObjectQuery
|
||||
? {
|
||||
items: objectsItems,
|
||||
items: objectsItems ?? null,
|
||||
query: objectsQuery,
|
||||
loadMore
|
||||
}
|
||||
: {
|
||||
items: pathsItems,
|
||||
items: pathsItems ?? null,
|
||||
query: pathsQuery,
|
||||
loadMore
|
||||
};
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import { getIcon } from '@sd/assets/util';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import 'react-loading-skeleton/dist/skeleton.css';
|
||||
import { Category } from '@sd/client';
|
||||
import { useIsDark } from '../../../hooks';
|
||||
import { ExplorerContext } from '../Explorer/Context';
|
||||
import ContextMenu, { ObjectItems } from '../Explorer/ContextMenu';
|
||||
import { Conditional } from '../Explorer/ContextMenu/ConditionalItem';
|
||||
import { Inspector } from '../Explorer/Inspector';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import View from '../Explorer/View';
|
||||
import { useExplorerStore } from '../Explorer/store';
|
||||
import { useExplorer } from '../Explorer/useExplorer';
|
||||
import { usePageLayoutContext } from '../PageLayout/Context';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
import Statistics from '../overview/Statistics';
|
||||
|
@ -43,24 +45,23 @@ export const Component = () => {
|
|||
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category>('Recents');
|
||||
|
||||
const { items, query, loadMore } = useItems(selectedCategory);
|
||||
const { items, loadMore } = useItems(selectedCategory);
|
||||
|
||||
const [selectedItemId, setSelectedItemId] = useState<number>();
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() => (selectedItemId ? items?.find((item) => item.item.id === selectedItemId) : undefined),
|
||||
[selectedItemId, items]
|
||||
);
|
||||
const explorer = useExplorer({
|
||||
items,
|
||||
loadMore,
|
||||
scrollRef: page.ref
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (page?.ref.current) {
|
||||
if (!page.ref.current) return;
|
||||
|
||||
const { scrollTop } = page.ref.current;
|
||||
if (scrollTop > 100) page.ref.current.scrollTo({ top: 100 });
|
||||
}
|
||||
}, [selectedCategory, page?.ref]);
|
||||
}, [selectedCategory, page.ref]);
|
||||
|
||||
return (
|
||||
<ExplorerContext.Provider value={{}}>
|
||||
<ExplorerContext.Provider value={explorer}>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
|
||||
<Statistics />
|
||||
|
@ -69,28 +70,12 @@ export const Component = () => {
|
|||
|
||||
<div className="flex flex-1">
|
||||
<View
|
||||
items={query.isLoading ? null : items || []}
|
||||
// TODO: Fix this type here.
|
||||
scrollRef={page?.ref as any}
|
||||
onLoadMore={loadMore}
|
||||
rowsBeforeLoadMore={5}
|
||||
selected={selectedItemId}
|
||||
onSelectedChange={setSelectedItemId}
|
||||
top={68}
|
||||
className={explorerStore.layoutMode === 'rows' ? 'min-w-0' : undefined}
|
||||
contextMenu={
|
||||
selectedItem ? (
|
||||
<ContextMenu
|
||||
item={selectedItem}
|
||||
extra={({ object }) => (
|
||||
<>
|
||||
{object && (
|
||||
<ObjectItems.RemoveFromRecents object={object} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : null
|
||||
<ContextMenu>
|
||||
{() => <Conditional items={[ObjectItems.RemoveFromRecents]} />}
|
||||
</ContextMenu>
|
||||
}
|
||||
emptyNotice={
|
||||
<div className="flex h-full flex-col items-center justify-center text-white">
|
||||
|
@ -111,9 +96,8 @@ export const Component = () => {
|
|||
|
||||
{explorerStore.showInspector && (
|
||||
<Inspector
|
||||
data={selectedItem}
|
||||
showThumbnail={explorerStore.layoutMode !== 'media'}
|
||||
className="custom-scroll inspector-scroll sticky top-[68px] h-auto w-[260px] shrink-0 self-start bg-app pb-4 pl-1.5 pr-1"
|
||||
className="custom-scroll inspector-scroll sticky top-[68px] h-full w-[260px] shrink-0 bg-app pb-4 pl-1.5 pr-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,9 @@ import { useZodSearchParams } from '~/hooks';
|
|||
import Explorer from './Explorer';
|
||||
import { ExplorerContext } from './Explorer/Context';
|
||||
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
|
||||
import { EmptyNotice } from './Explorer/View';
|
||||
import { getExplorerStore, useExplorerStore } from './Explorer/store';
|
||||
import { useExplorer } from './Explorer/useExplorer';
|
||||
import { TopBarPortal } from './TopBar/Portal';
|
||||
|
||||
const SearchExplorer = memo((props: { args: SearchParams }) => {
|
||||
|
@ -21,31 +23,35 @@ const SearchExplorer = memo((props: { args: SearchParams }) => {
|
|||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
const items = query.data?.items;
|
||||
const items = query.data?.items ?? null;
|
||||
|
||||
if (explorerStore.layoutMode !== 'media') return items;
|
||||
|
||||
return items?.filter((item) => {
|
||||
return (
|
||||
items?.filter((item) => {
|
||||
const { kind } = getExplorerItemData(item);
|
||||
return kind === 'Video' || kind === 'Image';
|
||||
});
|
||||
}) || null
|
||||
);
|
||||
}, [query.data, explorerStore.layoutMode]);
|
||||
|
||||
const explorer = useExplorer({
|
||||
items
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{items && items.length > 0 ? (
|
||||
<ExplorerContext.Provider value={{}}>
|
||||
{search ? (
|
||||
<ExplorerContext.Provider value={explorer}>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
<Explorer items={items} />
|
||||
<Explorer
|
||||
emptyNotice={<EmptyNotice message={`No results found for "${search}"`} />}
|
||||
/>
|
||||
</ExplorerContext.Provider>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
{!search && (
|
||||
<MagnifyingGlass size={110} className="mb-5 text-ink-faint" opacity={0.3} />
|
||||
)}
|
||||
<p className="text-xs text-ink-faint">
|
||||
{search ? `No results found for "${search}"` : 'Search for files...'}
|
||||
</p>
|
||||
<p className="text-xs text-ink-faint">Search for files...</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
@ -58,7 +64,7 @@ export const Component = () => {
|
|||
const search = useDeferredValue(searchParams);
|
||||
|
||||
return (
|
||||
<Suspense fallback="LOADING FIRST RENDER">
|
||||
<Suspense>
|
||||
<SearchExplorer args={search} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useLibraryMutation, usePlausibleEvent } from '@sd/client';
|
||||
import { Object, useLibraryMutation, usePlausibleEvent } from '@sd/client';
|
||||
import { Dialog, InputField, UseDialogProps, useDialog, useZodForm, z } from '@sd/ui';
|
||||
import { ColorPicker } from '~/components';
|
||||
|
||||
|
@ -7,7 +7,7 @@ const schema = z.object({
|
|||
color: z.string()
|
||||
});
|
||||
|
||||
export default (props: UseDialogProps & { assignToObject?: number }) => {
|
||||
export default (props: UseDialogProps & { objects?: Object[] }) => {
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const form = useZodForm({
|
||||
|
@ -24,10 +24,10 @@ export default (props: UseDialogProps & { assignToObject?: number }) => {
|
|||
|
||||
submitPlausibleEvent({ event: { type: 'tagCreate' } });
|
||||
|
||||
if (props.assignToObject !== undefined) {
|
||||
if (props.objects !== undefined) {
|
||||
await assignTag.mutateAsync({
|
||||
tag_id: tag.id,
|
||||
object_ids: [props.assignToObject],
|
||||
object_ids: props.objects.map((o) => o.id),
|
||||
unassign: false
|
||||
});
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import Explorer from '../Explorer';
|
|||
import { ExplorerContext } from '../Explorer/Context';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { EmptyNotice } from '../Explorer/View';
|
||||
import { useExplorer } from '../Explorer/useExplorer';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
||||
export const Component = () => {
|
||||
|
@ -22,20 +23,20 @@ export const Component = () => {
|
|||
|
||||
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
|
||||
|
||||
return (
|
||||
<ExplorerContext.Provider
|
||||
value={{
|
||||
const explorer = useExplorer({
|
||||
items: explorerData.data?.items || null,
|
||||
parent: tag.data
|
||||
? {
|
||||
type: 'Tag',
|
||||
tag: tag.data
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
});
|
||||
|
||||
return (
|
||||
<ExplorerContext.Provider value={explorer}>
|
||||
<TopBarPortal right={<DefaultTopBarOptions />} />
|
||||
<Explorer
|
||||
items={explorerData.data?.items || null}
|
||||
emptyNotice={
|
||||
<EmptyNotice
|
||||
icon={<img className="h-32 w-32" src={getIcon(iconNames.Tags)} />}
|
||||
|
|
|
@ -12,6 +12,20 @@ body {
|
|||
@apply bg-app;
|
||||
}
|
||||
|
||||
.frame::before {
|
||||
@apply bg-app-frame;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
mask: linear-gradient(black, black) content-box content-box, linear-gradient(black, black);
|
||||
mask-composite: xor;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.has-blur-effects {
|
||||
.app-background {
|
||||
// adjust macOS blur intensity here
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||
import clsx from 'clsx';
|
||||
import { Plus } from 'phosphor-react';
|
||||
import { useRef } from 'react';
|
||||
import { useLibraryMutation, useLibraryQuery, usePlausibleEvent } from '@sd/client';
|
||||
import { Object, useLibraryMutation, useLibraryQuery, usePlausibleEvent } from '@sd/client';
|
||||
import {
|
||||
ContextMenu,
|
||||
DropdownMenu,
|
||||
|
@ -16,15 +16,17 @@ import { useOperatingSystem } from '~/hooks';
|
|||
import { useScrolled } from '~/hooks/useScrolled';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
|
||||
export default (props: { objectId: number }) => {
|
||||
export default (props: { objects: Object[] }) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = keybindForOs(os);
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const tags = useLibraryQuery(['tags.list'], { suspense: true });
|
||||
const tagsForObject = useLibraryQuery(['tags.getForObject', props.objectId], {
|
||||
suspense: true
|
||||
});
|
||||
// Map<tag::id, Vec<object::id>>
|
||||
const tagsWithObjects = useLibraryQuery([
|
||||
'tags.getWithObjects',
|
||||
props.objects.map(({ id }) => id)
|
||||
]);
|
||||
|
||||
const assignTag = useLibraryMutation('tags.assign', {
|
||||
onSuccess: () => {
|
||||
|
@ -55,9 +57,7 @@ export default (props: { objectId: number }) => {
|
|||
iconProps={{ size: 15 }}
|
||||
keybind={keybind([ModifierKeys.Control], ['N'])}
|
||||
onClick={() => {
|
||||
dialogManager.create((dp) => (
|
||||
<CreateDialog {...dp} assignToObject={props.objectId} />
|
||||
));
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} objects={props.objects} />);
|
||||
}}
|
||||
/>
|
||||
<Menu.Separator className={clsx('mx-0 mb-0 transition', isScrolled && 'shadow')} />
|
||||
|
@ -80,9 +80,16 @@ export default (props: { objectId: number }) => {
|
|||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const tag = tags.data[virtualRow.index];
|
||||
const active = !!tagsForObject.data?.find((t) => t.id === tag?.id);
|
||||
|
||||
if (!tag) return null;
|
||||
|
||||
const objectsWithTag = tagsWithObjects.data?.[tag?.id];
|
||||
|
||||
// only unassign if all objects have tag
|
||||
// this is the same functionality as finder
|
||||
const unassign = objectsWithTag?.length === props.objects.length;
|
||||
|
||||
// TODO: UI to differentiate tag assigning when some objects have tag when no objects have tag - ENG-965
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={virtualRow.index}
|
||||
|
@ -94,12 +101,24 @@ export default (props: { objectId: number }) => {
|
|||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
assignTag.mutate({
|
||||
|
||||
await assignTag.mutateAsync({
|
||||
unassign,
|
||||
tag_id: tag.id,
|
||||
object_ids: [props.objectId],
|
||||
unassign: active
|
||||
object_ids: unassign
|
||||
? // use objects that already have tag
|
||||
objectsWithTag
|
||||
: // use objects that don't have tag
|
||||
props.objects
|
||||
.filter(
|
||||
(o) =>
|
||||
!objectsWithTag?.some(
|
||||
(ot) => ot === o.id
|
||||
)
|
||||
)
|
||||
.map((o) => o.id)
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -107,7 +126,11 @@ export default (props: { objectId: number }) => {
|
|||
className="mr-0.5 h-[15px] w-[15px] shrink-0 rounded-full border"
|
||||
style={{
|
||||
backgroundColor:
|
||||
active && tag.color ? tag.color : 'transparent',
|
||||
objectsWithTag &&
|
||||
objectsWithTag.length > 0 &&
|
||||
tag.color
|
||||
? tag.color
|
||||
: 'transparent',
|
||||
borderColor: tag.color || '#efefef'
|
||||
}}
|
||||
/>
|
|
@ -1,121 +1,175 @@
|
|||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import React, {
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
cloneElement,
|
||||
createContext,
|
||||
useContext,
|
||||
useRef
|
||||
} from 'react';
|
||||
import React, { ReactNode, useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import { RefObject, useEffect, useMemo, useState } from 'react';
|
||||
import Selecto, { SelectoProps } from 'react-selecto';
|
||||
import { useBoundingclientrect, useIntersectionObserverRef, useKey, useKeys } from 'rooks';
|
||||
import { useMutationObserver } from 'rooks';
|
||||
import useResizeObserver from 'use-resize-observer';
|
||||
import { TOP_BAR_HEIGHT } from '~/app/$libraryId/TopBar';
|
||||
|
||||
type GridListSelection = number | number[];
|
||||
interface GridListDefaults<T extends GridListSelection> {
|
||||
type ItemData = any | undefined;
|
||||
type ItemId = number | string;
|
||||
|
||||
export interface GridListItem<IdT extends ItemId = number, DataT extends ItemData = undefined> {
|
||||
index: number;
|
||||
id: IdT;
|
||||
row: number;
|
||||
column: number;
|
||||
rect: Omit<DOMRect, 'toJSON'>;
|
||||
data: DataT;
|
||||
}
|
||||
|
||||
export interface UseGridListProps<IdT extends ItemId = number, DataT extends ItemData = undefined> {
|
||||
count: number;
|
||||
scrollRef: RefObject<HTMLElement>;
|
||||
ref: RefObject<HTMLElement>;
|
||||
padding?: number | { x?: number; y?: number };
|
||||
gap?: number | { x?: number; y?: number };
|
||||
children: (props: {
|
||||
index: number;
|
||||
item: (props: GridListItemProps) => JSX.Element;
|
||||
}) => JSX.Element | null;
|
||||
selected?: T;
|
||||
onSelectedChange?: (change: T) => void;
|
||||
selectable?: boolean;
|
||||
onSelect?: (index: number) => void;
|
||||
onDeselect?: (index: number) => void;
|
||||
overscan?: number;
|
||||
top?: number;
|
||||
onLoadMore?: () => void;
|
||||
rowsBeforeLoadMore?: number;
|
||||
preventSelection?: boolean;
|
||||
preventContextMenuSelection?: boolean;
|
||||
}
|
||||
interface WrapProps<T extends GridListSelection> extends GridListDefaults<T> {
|
||||
size: number | { width: number; height: number };
|
||||
onLoadMore?: () => void;
|
||||
getItemId?: (index: number) => IdT | undefined;
|
||||
getItemData?: (index: number) => DataT;
|
||||
size?: number | { width: number; height: number };
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
interface ResizeProps<T extends GridListSelection> extends GridListDefaults<T> {
|
||||
columns: number;
|
||||
}
|
||||
|
||||
type GridListProps<T extends GridListSelection> = WrapProps<T> | ResizeProps<T>;
|
||||
|
||||
export const GridList = <T extends GridListSelection>({
|
||||
selectable = true,
|
||||
export const useGridList = <IdT extends ItemId = number, DataT extends ItemData = undefined>({
|
||||
padding,
|
||||
gap,
|
||||
size,
|
||||
columns,
|
||||
ref,
|
||||
getItemId,
|
||||
getItemData,
|
||||
...props
|
||||
}: GridListProps<T>) => {
|
||||
const scrollBarWidth = 6;
|
||||
}: UseGridListProps<IdT, DataT>) => {
|
||||
const { width } = useResizeObserver({ ref });
|
||||
|
||||
const multiSelect = Array.isArray(props.selected);
|
||||
const paddingX = (typeof padding === 'object' ? padding.x : padding) || 0;
|
||||
const paddingY = (typeof padding === 'object' ? padding.y : padding) || 0;
|
||||
|
||||
const paddingX = (typeof props.padding === 'object' ? props.padding.x : props.padding) || 0;
|
||||
const paddingY = (typeof props.padding === 'object' ? props.padding.y : props.padding) || 0;
|
||||
const gapX = (typeof gap === 'object' ? gap.x : gap) || 0;
|
||||
const gapY = (typeof gap === 'object' ? gap.y : gap) || 0;
|
||||
|
||||
const gapX = (typeof props.gap === 'object' ? props.gap.x : props.gap) || 0;
|
||||
const gapY = (typeof props.gap === 'object' ? props.gap.y : props.gap) || 0;
|
||||
const itemWidth = size ? (typeof size === 'object' ? size.width : size) : undefined;
|
||||
const itemHeight = size ? (typeof size === 'object' ? size.height : size) : undefined;
|
||||
|
||||
const itemWidth =
|
||||
'size' in props
|
||||
? typeof props.size === 'object'
|
||||
? props.size.width
|
||||
: props.size
|
||||
: undefined;
|
||||
const gridWidth = width ? width - (paddingX || 0) * 2 : 0;
|
||||
|
||||
const itemHeight =
|
||||
'size' in props
|
||||
? typeof props.size === 'object'
|
||||
? props.size.height
|
||||
: props.size
|
||||
: undefined;
|
||||
let columnCount = columns || 0;
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
if (!columns && itemWidth) {
|
||||
let columns = Math.floor(gridWidth / itemWidth);
|
||||
if (gapX) columns = Math.floor((gridWidth - (columns - 1) * gapX) / itemWidth);
|
||||
columnCount = columns;
|
||||
}
|
||||
|
||||
const { width = 0 } = useResizeObserver({ ref: ref });
|
||||
const rowCount = columnCount > 0 ? Math.ceil(props.count / columnCount) : 0;
|
||||
|
||||
const rect = useBoundingclientrect(ref);
|
||||
const virtualItemWidth =
|
||||
columnCount > 0 ? (gridWidth - (columnCount - 1) * gapX) / columnCount : 0;
|
||||
|
||||
const selecto = useRef<Selecto>(null);
|
||||
|
||||
const [scrollOptions, setScrollOptions] = React.useState<SelectoProps['scrollOptions']>();
|
||||
const [listOffset, setListOffset] = useState(0);
|
||||
|
||||
const gridWidth = width - (paddingX || 0) * 2;
|
||||
|
||||
// Virtualizer count calculation
|
||||
const amountOfColumns =
|
||||
'columns' in props ? props.columns : itemWidth ? Math.floor(gridWidth / itemWidth) : 0;
|
||||
const amountOfRows = amountOfColumns > 0 ? Math.ceil(props.count / amountOfColumns) : 0;
|
||||
|
||||
// Virtualizer item size calculation
|
||||
const virtualItemWidth = amountOfColumns > 0 ? gridWidth / amountOfColumns : 0;
|
||||
const virtualItemHeight = itemHeight || virtualItemWidth;
|
||||
|
||||
const getItem = useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= props.count) return;
|
||||
|
||||
const id = getItemId?.(index) || index;
|
||||
|
||||
const data = getItemData?.(index) as DataT;
|
||||
|
||||
const column = index % columnCount;
|
||||
const row = Math.floor(index / columnCount);
|
||||
|
||||
const x = paddingX + (column !== 0 ? gapX : 0) * column + virtualItemWidth * column;
|
||||
const y = paddingY + (row !== 0 ? gapY : 0) * row + virtualItemHeight * row;
|
||||
|
||||
const item: GridListItem<typeof id, DataT> = {
|
||||
index,
|
||||
id,
|
||||
data,
|
||||
row,
|
||||
column,
|
||||
rect: {
|
||||
height: virtualItemHeight,
|
||||
width: virtualItemWidth,
|
||||
x,
|
||||
y,
|
||||
top: y,
|
||||
bottom: y + virtualItemHeight,
|
||||
left: x,
|
||||
right: x + virtualItemWidth
|
||||
}
|
||||
};
|
||||
|
||||
return item;
|
||||
},
|
||||
[
|
||||
columnCount,
|
||||
props.count,
|
||||
gapX,
|
||||
gapY,
|
||||
getItemId,
|
||||
getItemData,
|
||||
paddingX,
|
||||
paddingY,
|
||||
virtualItemHeight,
|
||||
virtualItemWidth
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
columnCount,
|
||||
rowCount,
|
||||
width: gridWidth,
|
||||
padding: { x: paddingX, y: paddingY },
|
||||
gap: { x: gapX, y: gapY },
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
virtualItemHeight,
|
||||
virtualItemWidth,
|
||||
getItem,
|
||||
...props
|
||||
};
|
||||
};
|
||||
|
||||
export interface GridListProps {
|
||||
grid: ReturnType<typeof useGridList>;
|
||||
scrollRef: RefObject<HTMLElement>;
|
||||
children: (index: number) => ReactNode;
|
||||
}
|
||||
|
||||
export const GridList = ({ grid, children, scrollRef }: GridListProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [listOffset, setListOffset] = useState(0);
|
||||
|
||||
const getHeight = useCallback(
|
||||
(index: number) => grid.virtualItemHeight + (index !== 0 ? grid.gap.y : 0),
|
||||
[grid.virtualItemHeight, grid.gap.y]
|
||||
);
|
||||
|
||||
const getWidth = useCallback(
|
||||
(index: number) => grid.virtualItemWidth + (index !== 0 ? grid.gap.x : 0),
|
||||
[grid.virtualItemWidth, grid.gap.x]
|
||||
);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: amountOfRows,
|
||||
getScrollElement: () => props.scrollRef.current,
|
||||
estimateSize: () => virtualItemHeight,
|
||||
measureElement: () => virtualItemHeight,
|
||||
paddingStart: paddingY,
|
||||
paddingEnd: paddingY,
|
||||
overscan: props.overscan,
|
||||
count: grid.rowCount,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: getHeight,
|
||||
paddingStart: grid.padding.y,
|
||||
paddingEnd: grid.padding.y,
|
||||
overscan: grid.overscan,
|
||||
scrollMargin: listOffset
|
||||
});
|
||||
|
||||
const columnVirtualizer = useVirtualizer({
|
||||
horizontal: true,
|
||||
count: amountOfColumns,
|
||||
getScrollElement: () => props.scrollRef.current,
|
||||
estimateSize: () => virtualItemWidth,
|
||||
measureElement: () => virtualItemWidth,
|
||||
paddingStart: paddingX,
|
||||
paddingEnd: paddingX
|
||||
count: grid.columnCount,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: getWidth,
|
||||
paddingStart: grid.padding.x,
|
||||
paddingEnd: grid.padding.x
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
@ -125,7 +179,7 @@ export const GridList = <T extends GridListSelection>({
|
|||
useEffect(() => {
|
||||
rowVirtualizer.measure();
|
||||
columnVirtualizer.measure();
|
||||
}, [rowVirtualizer, columnVirtualizer, virtualItemWidth, virtualItemHeight]);
|
||||
}, [rowVirtualizer, columnVirtualizer, grid.virtualItemWidth, grid.virtualItemHeight]);
|
||||
|
||||
// Force recalculate range
|
||||
// https://github.com/TanStack/virtual/issues/485
|
||||
|
@ -134,143 +188,30 @@ export const GridList = <T extends GridListSelection>({
|
|||
rowVirtualizer.calculateRange();
|
||||
// @ts-ignore
|
||||
columnVirtualizer.calculateRange();
|
||||
}, [amountOfRows, amountOfColumns, rowVirtualizer, columnVirtualizer]);
|
||||
|
||||
// Set Selecto scroll options
|
||||
useEffect(() => {
|
||||
setScrollOptions({
|
||||
container: props.scrollRef.current!,
|
||||
getScrollPosition: () => {
|
||||
// FIXME: Eslint is right here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
return [props.scrollRef.current?.scrollLeft!, props.scrollRef.current?.scrollTop!];
|
||||
},
|
||||
throttleTime: 30,
|
||||
threshold: 0
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check Selecto scroll
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
selecto.current?.checkScroll();
|
||||
};
|
||||
|
||||
props.scrollRef.current?.addEventListener('scroll', handleScroll);
|
||||
return () => props.scrollRef.current?.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowVirtualizer, columnVirtualizer, grid.columnCount, grid.rowCount]);
|
||||
|
||||
useEffect(() => {
|
||||
setListOffset(ref.current?.offsetTop || 0);
|
||||
}, [rect]);
|
||||
|
||||
// Handle key Selection
|
||||
useKey(['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'], (e) => {
|
||||
!props.preventSelection && e.preventDefault();
|
||||
|
||||
if (!selectable || !props.onSelectedChange || props.preventSelection) return;
|
||||
|
||||
const selectedItems = selecto.current?.getSelectedTargets() || [
|
||||
...document.querySelectorAll<HTMLDivElement>(`[data-selected="true"]`)
|
||||
];
|
||||
|
||||
const lastItem = selectedItems[selectedItems.length - 1];
|
||||
|
||||
if (lastItem) {
|
||||
const currentIndex = Number(lastItem.getAttribute('data-selectable-index'));
|
||||
let newIndex = currentIndex;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
newIndex += -amountOfColumns;
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
newIndex += amountOfColumns;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
newIndex += 1;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
newIndex += -1;
|
||||
break;
|
||||
}
|
||||
|
||||
const newSelectedItem = document.querySelector<HTMLDivElement>(
|
||||
`[data-selectable-index="${newIndex}"]`
|
||||
);
|
||||
|
||||
if (newSelectedItem) {
|
||||
if (!multiSelect) {
|
||||
const id = Number(newSelectedItem.getAttribute('data-selectable-id'));
|
||||
props.onSelectedChange(id as T);
|
||||
} else {
|
||||
const addToGridListSelection = e.shiftKey;
|
||||
|
||||
selecto.current?.setSelectedTargets([
|
||||
...(addToGridListSelection ? selectedItems : []),
|
||||
newSelectedItem
|
||||
]);
|
||||
|
||||
props.onSelectedChange(
|
||||
[...(addToGridListSelection ? selectedItems : []), newSelectedItem].map(
|
||||
(el) => Number(el.getAttribute('data-selectable-id'))
|
||||
) as T
|
||||
);
|
||||
}
|
||||
|
||||
if (props.scrollRef.current) {
|
||||
const direction = newIndex > currentIndex ? 'down' : 'up';
|
||||
|
||||
const itemRect = newSelectedItem.getBoundingClientRect();
|
||||
const scrollRect = props.scrollRef.current.getBoundingClientRect();
|
||||
|
||||
const paddingTop = parseInt(
|
||||
getComputedStyle(props.scrollRef.current).paddingTop
|
||||
);
|
||||
|
||||
const top = props.top ? paddingTop + props.top : paddingTop;
|
||||
|
||||
switch (direction) {
|
||||
case 'up': {
|
||||
if (itemRect.top < top) {
|
||||
props.scrollRef.current.scrollBy({
|
||||
top: itemRect.top - top - paddingY - 1,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'down': {
|
||||
if (itemRect.bottom > scrollRect.height) {
|
||||
props.scrollRef.current.scrollBy({
|
||||
top: itemRect.bottom - scrollRect.height + paddingY + 1,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (props.onLoadMore) {
|
||||
if (grid.onLoadMore) {
|
||||
const lastRow = virtualRows[virtualRows.length - 1];
|
||||
if (lastRow) {
|
||||
const rowsBeforeLoadMore = props.rowsBeforeLoadMore || 1;
|
||||
const rowsBeforeLoadMore = grid.rowsBeforeLoadMore || 1;
|
||||
|
||||
const loadMoreOnIndex =
|
||||
rowsBeforeLoadMore > amountOfRows ||
|
||||
lastRow.index > amountOfRows - rowsBeforeLoadMore
|
||||
? amountOfRows - 1
|
||||
: amountOfRows - rowsBeforeLoadMore;
|
||||
rowsBeforeLoadMore > grid.rowCount ||
|
||||
lastRow.index > grid.rowCount - rowsBeforeLoadMore
|
||||
? grid.rowCount - 1
|
||||
: grid.rowCount - rowsBeforeLoadMore;
|
||||
|
||||
if (lastRow.index === loadMoreOnIndex) props.onLoadMore();
|
||||
if (lastRow.index === loadMoreOnIndex) grid.onLoadMore();
|
||||
}
|
||||
}
|
||||
}, [virtualRows, amountOfRows, props.rowsBeforeLoadMore, props.onLoadMore]);
|
||||
}, [virtualRows, grid.rowCount, grid.rowsBeforeLoadMore, grid.onLoadMore, grid]);
|
||||
|
||||
useMutationObserver(scrollRef, () => setListOffset(ref.current?.offsetTop ?? 0));
|
||||
|
||||
useLayoutEffect(() => setListOffset(ref.current?.offsetTop ?? 0), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -280,54 +221,14 @@ export const GridList = <T extends GridListSelection>({
|
|||
height: `${rowVirtualizer.getTotalSize()}px`
|
||||
}}
|
||||
>
|
||||
{multiSelect && (
|
||||
<Selecto
|
||||
ref={selecto}
|
||||
dragContainer={ref.current}
|
||||
boundContainer={ref.current}
|
||||
selectableTargets={['[data-selectable]']}
|
||||
toggleContinueSelect={'shift'}
|
||||
hitRate={0}
|
||||
scrollOptions={scrollOptions}
|
||||
onDragStart={(e) => {
|
||||
if (e.inputEvent.target.nodeName === 'BUTTON') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
selecto.current;
|
||||
props.scrollRef.current?.scrollBy(
|
||||
e.direction[0]! * 10,
|
||||
e.direction[1]! * 10
|
||||
);
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
const set = new Set(props.selected as number[]);
|
||||
|
||||
e.removed.forEach((el) => {
|
||||
set.delete(Number(el.getAttribute('data-selectable-id')));
|
||||
});
|
||||
|
||||
e.added.forEach((el) => {
|
||||
set.add(Number(el.getAttribute('data-selectable-id')));
|
||||
});
|
||||
|
||||
props.onSelectedChange?.([...set] as T);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{width !== 0 && (
|
||||
<SelectoContext.Provider value={selecto}>
|
||||
{virtualRows.map((virtualRow) => (
|
||||
{grid.width > 0 &&
|
||||
virtualRows.map((virtualRow) => (
|
||||
<React.Fragment key={virtualRow.index}>
|
||||
{virtualColumns.map((virtualColumn) => {
|
||||
const index =
|
||||
virtualRow.index * amountOfColumns + virtualColumn.index;
|
||||
const item = props.children({ index, item: GridListItem });
|
||||
const index = virtualRow.index * grid.columnCount + virtualColumn.index;
|
||||
|
||||
if (index >= grid.count) return null;
|
||||
|
||||
if (!item) return null;
|
||||
return (
|
||||
<div
|
||||
key={virtualColumn.index}
|
||||
|
@ -340,95 +241,26 @@ export const GridList = <T extends GridListSelection>({
|
|||
transform: `translateX(${
|
||||
virtualColumn.start
|
||||
}px) translateY(${
|
||||
virtualRow.start -
|
||||
rowVirtualizer.options.scrollMargin
|
||||
}px)`
|
||||
virtualRow.start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
paddingLeft: virtualColumn.index !== 0 ? grid.gap.x : 0,
|
||||
paddingTop: virtualRow.index !== 0 ? grid.gap.y : 0
|
||||
}}
|
||||
>
|
||||
{cloneElement<GridListItemProps>(item, {
|
||||
selectable: selectable && !!props.onSelectedChange,
|
||||
index,
|
||||
style: { width: itemWidth },
|
||||
onMouseDown: (id) => {
|
||||
!multiSelect && props.onSelectedChange?.(id as T);
|
||||
},
|
||||
onContextMenu: (id) => {
|
||||
!props.preventContextMenuSelection &&
|
||||
!multiSelect &&
|
||||
props.onSelectedChange?.(id as T);
|
||||
}
|
||||
})}
|
||||
<div
|
||||
className="m-auto"
|
||||
style={{
|
||||
width: grid.itemWidth || '100%',
|
||||
height: grid.itemHeight || '100%'
|
||||
}}
|
||||
>
|
||||
{children(index)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</SelectoContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectoContext = createContext<React.RefObject<Selecto>>(undefined!);
|
||||
const useSelecto = () => useContext(SelectoContext);
|
||||
|
||||
interface GridListItemProps
|
||||
extends PropsWithChildren,
|
||||
Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'onMouseDown' | 'onContextMenu'> {
|
||||
selectable?: boolean;
|
||||
index?: number;
|
||||
selected?: boolean;
|
||||
id?: number;
|
||||
onMouseDown?: (id: number) => void;
|
||||
onContextMenu?: (id: number) => void;
|
||||
}
|
||||
|
||||
const GridListItem = ({ className, children, style, ...props }: GridListItemProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const selecto = useSelecto();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.selectable && props.selected && selecto.current) {
|
||||
const current = selecto.current.getSelectedTargets();
|
||||
selecto.current?.setSelectedTargets([
|
||||
...current.filter(
|
||||
(el) => el.getAttribute('data-selectable-id') !== String(props.id)
|
||||
),
|
||||
ref.current!
|
||||
]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectableProps = props.selectable
|
||||
? {
|
||||
'data-selectable': '',
|
||||
'data-selectable-id': props.id || props.index,
|
||||
'data-selectable-index': props.index,
|
||||
'data-selected': props.selected
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...selectableProps}
|
||||
style={style}
|
||||
className={clsx('mx-auto h-full', className)}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.button === 0 && props.onMouseDown && props.selectable) {
|
||||
const id = props.id || props.index;
|
||||
if (id) props.onMouseDown(id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (props.onContextMenu && props.selectable) {
|
||||
const id = props.id || props.index;
|
||||
if (id) props.onContextMenu(id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,14 +3,20 @@ import { ExplorerItem } from '@sd/client';
|
|||
import { dialogManager } from '@sd/ui';
|
||||
import DeleteDialog from '~/app/$libraryId/Explorer/FilePath/DeleteDialog';
|
||||
|
||||
export const useKeyDeleteFile = (selectedItem: ExplorerItem | null, location_id: number | null) => {
|
||||
export const useKeyDeleteFile = (selectedItems: Set<ExplorerItem>, locationId?: number | null) => {
|
||||
return useKey('Delete', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedItem || !location_id) return;
|
||||
if (!locationId) return;
|
||||
|
||||
const pathIds: number[] = [];
|
||||
|
||||
for (const item of selectedItems) {
|
||||
if (item.type === 'Path') pathIds.push(item.item.id);
|
||||
}
|
||||
|
||||
dialogManager.create((dp) => (
|
||||
<DeleteDialog {...dp} location_id={location_id} path_id={selectedItem.item.id} />
|
||||
<DeleteDialog {...dp} locationId={locationId} pathIds={pathIds} />
|
||||
));
|
||||
});
|
||||
};
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
"react-router": "6.9.0",
|
||||
"react-router-dom": "6.9.0",
|
||||
"react-scroll-sync": "^0.11.0",
|
||||
"react-selecto": "^1.22.3",
|
||||
"react-selecto": "^1.26.0",
|
||||
"react-sticky-el": "^2.1.0",
|
||||
"react-use-draggable-scroll": "^0.4.7",
|
||||
"remix-params-helper": "^0.4.10",
|
||||
|
|
|
@ -9,3 +9,7 @@ export function useForceUpdate() {
|
|||
const [, setTick] = useState(0);
|
||||
return useCallback(() => setTick((tick) => tick + 1), []);
|
||||
}
|
||||
|
||||
export type NonEmptyArray<T> = [T, ...T[]];
|
||||
|
||||
export const isNonEmpty = <T,>(input: T[]): input is NonEmptyArray<T> => input.length > 0;
|
||||
|
|
|
@ -29,6 +29,7 @@ export type Procedures = {
|
|||
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
|
||||
{ key: "tags.get", input: LibraryArgs<number>, result: Tag | null } |
|
||||
{ key: "tags.getForObject", input: LibraryArgs<number>, result: Tag[] } |
|
||||
{ key: "tags.getWithObjects", input: LibraryArgs<number[]>, result: { [key: number]: number[] } } |
|
||||
{ key: "tags.list", input: LibraryArgs<null>, result: Tag[] } |
|
||||
{ key: "volumes.list", input: never, result: Volume[] },
|
||||
mutations:
|
||||
|
@ -41,7 +42,7 @@ export type Procedures = {
|
|||
{ key: "files.renameFile", input: LibraryArgs<RenameFileArgs>, result: null } |
|
||||
{ key: "files.setFavorite", input: LibraryArgs<SetFavoriteArgs>, result: null } |
|
||||
{ key: "files.setNote", input: LibraryArgs<SetNoteArgs>, result: null } |
|
||||
{ key: "files.updateAccessTime", input: LibraryArgs<number>, result: null } |
|
||||
{ key: "files.updateAccessTime", input: LibraryArgs<number[]>, result: null } |
|
||||
{ key: "invalidation.test-invalidate-mutation", input: LibraryArgs<null>, result: null } |
|
||||
{ key: "jobs.cancel", input: LibraryArgs<string>, result: null } |
|
||||
{ key: "jobs.clear", input: LibraryArgs<string>, result: null } |
|
||||
|
|
|
@ -27,7 +27,7 @@ const getDecimalUnit = (n: bigint) => {
|
|||
);
|
||||
};
|
||||
|
||||
function bytesToNumber(bytes: string[] | number[] | bigint[]) {
|
||||
export function bytesToNumber(bytes: string[] | number[] | bigint[]) {
|
||||
return bytes
|
||||
.map((b) => (typeof b === 'bigint' ? b : BigInt(b)))
|
||||
.reduce((acc, curr, i) => acc + curr * 256n ** BigInt(bytes.length - i - 1));
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { ExplorerItem } from '../core';
|
||||
import { useMemo } from 'react';
|
||||
import { ExplorerItem, FilePath, Object } from '../core';
|
||||
import { ObjectKind, ObjectKindKey } from './objectKind';
|
||||
|
||||
export function getItemObject(data: ExplorerItem) {
|
||||
|
@ -31,3 +32,54 @@ export function getExplorerItemData(data: ExplorerItem) {
|
|||
thumbnailKey: data.thumbnail_key
|
||||
};
|
||||
}
|
||||
|
||||
export const useItemsAsObjects = (items: ExplorerItem[]) => {
|
||||
return useMemo(() => {
|
||||
const array: Object[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case 'Path': {
|
||||
if (!item.item.object) return [];
|
||||
array.push(item.item.object);
|
||||
break;
|
||||
}
|
||||
case 'Object': {
|
||||
array.push(item.item);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return array;
|
||||
}, [items]);
|
||||
};
|
||||
|
||||
export const useItemsAsFilePaths = (items: ExplorerItem[]) => {
|
||||
return useMemo(() => {
|
||||
const array: FilePath[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case 'Path': {
|
||||
array.push(item.item);
|
||||
break;
|
||||
}
|
||||
case 'Object': {
|
||||
// this isn't good but it's the current behaviour
|
||||
const filePath = item.item.file_paths[0];
|
||||
if (filePath) array.push(filePath);
|
||||
else return [];
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return array;
|
||||
}, [items]);
|
||||
};
|
||||
|
|
|
@ -740,8 +740,8 @@ importers:
|
|||
specifier: ^0.11.0
|
||||
version: 0.11.0(react-dom@18.2.0)(react@18.2.0)
|
||||
react-selecto:
|
||||
specifier: ^1.22.3
|
||||
version: 1.22.3
|
||||
specifier: ^1.26.0
|
||||
version: 1.26.0
|
||||
react-sticky-el:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(react-dom@18.2.0)(react@18.2.0)
|
||||
|
@ -21001,10 +21001,10 @@ packages:
|
|||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-selecto@1.22.3:
|
||||
resolution: {integrity: sha512-xCBnXEfuoWx5mczCL1wqm8v8Crq3JhIh5bJO5dCrWJFQyvBv0UbN1AGYrt9ZGxsxyitRj5cPdFq/Kf3KkpsuSA==}
|
||||
/react-selecto@1.26.0:
|
||||
resolution: {integrity: sha512-aBTZEYA68uE+o8TytNjTb2GpIn4oKEv0U4LIow3cspJQlF/PdAnBwkq9UuiKVuFluu5kfLQ7Keu3S2Tihlmw0g==}
|
||||
dependencies:
|
||||
selecto: 1.22.6
|
||||
selecto: 1.26.0
|
||||
dev: false
|
||||
|
||||
/react-shallow-renderer@16.15.0(react@18.2.0):
|
||||
|
@ -21863,8 +21863,8 @@ packages:
|
|||
kind-of: 6.0.3
|
||||
dev: false
|
||||
|
||||
/selecto@1.22.6:
|
||||
resolution: {integrity: sha512-9LqyQWmT032JZe3Rtg/urvXbkZJV/EFLGo115KQCv7wL9nuqindmJuzFQBRTB1xUcglWavGgnFZRp7EknFZh9Q==}
|
||||
/selecto@1.26.0:
|
||||
resolution: {integrity: sha512-cEFKdv5rmkF6pf2OScQJllaNp4UJy/FvviB40ZaMSHrQCxC72X/Q6uhzW1tlb2RE+0danvUNJTs64cI9VXtUyg==}
|
||||
dependencies:
|
||||
'@daybrush/utils': 1.13.0
|
||||
'@egjs/children-differ': 1.0.1
|
||||
|
|
Loading…
Reference in a new issue