[ENG-1353] explorer dnd (#1737)

* locations dnd

* fix icon

* reduce navigate timeout

* fix types

* another

* fix drag overlay count

* Update pnpm-lock.yaml

* merge

* ephemeral support and other improvements

* merge

* Tag dnd

* merge

* type

* merge

* remove offset

* update dnd logic to not depend on drag source

* handle allowed types if parent isn't available

* saved searches dnd navigation

* well

* rendering

* Update pnpm-lock.yaml

* types

* remove width

* Temporary solution

* merge

* @dnd-kit/utilities

* Update pnpm-lock.yaml

* explorer path dnd

* remove unused drag hook

* fix dnd on LayeredFileIcon

---------

Co-authored-by: Brendan Allan <brendonovich@outlook.com>
This commit is contained in:
nikec 2023-12-13 12:59:27 +01:00 committed by GitHub
parent 0db883d0ca
commit caf4fc5cde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 2787 additions and 1769 deletions

View file

@ -26,4 +26,4 @@ export const ExplorerContextProvider = <TExplorer extends UseExplorer<any>>({
children
}: PropsWithChildren<{
explorer: TExplorer;
}>) => <ExplorerContext.Provider value={explorer as any}>{children}</ExplorerContext.Provider>;
}>) => <ExplorerContext.Provider value={explorer}>{children}</ExplorerContext.Provider>;

View file

@ -15,7 +15,6 @@ import { getQuickPreviewStore } from '../QuickPreview/store';
import { RevealInNativeExplorerBase } from '../RevealInNativeExplorer';
import { getExplorerStore, useExplorerStore } from '../store';
import { useViewItemDoubleClick } from '../View/ViewItem';
import { useExplorerViewContext } from '../ViewContext';
import { Conditional, ConditionalItem } from './ConditionalItem';
import { useContextMenuContext } from './context';
import OpenWith from './OpenWith';
@ -101,7 +100,6 @@ export const Rename = new ConditionalItem({
return {};
},
Component: () => {
const explorerView = useExplorerViewContext();
const keybind = useKeybindFactory();
const os = useOperatingSystem(true);
@ -109,7 +107,7 @@ export const Rename = new ConditionalItem({
<ContextMenu.Item
label="Rename"
keybind={keybind([], [os === 'windows' ? 'F2' : 'Enter'])}
onClick={() => explorerView.setIsRenaming(true)}
onClick={() => (getExplorerStore().isRenaming = true)}
/>
);
}

View file

@ -0,0 +1,98 @@
import type { ClientRect, Modifier } from '@dnd-kit/core';
import { DragOverlay as DragOverlayPrimitive } from '@dnd-kit/core';
import { getEventCoordinates } from '@dnd-kit/utilities';
import clsx from 'clsx';
import { memo, useEffect, useRef } from 'react';
import { ExplorerItem } from '@sd/client';
import { useIsDark } from '~/hooks';
import { FileThumb } from './FilePath/Thumb';
import { useExplorerStore } from './store';
import { RenamableItemText } from './View/RenamableItemText';
const useSnapToCursorModifier = () => {
const explorerStore = useExplorerStore();
const initialRect = useRef<ClientRect | null>(null);
const modifier: Modifier = ({ activatorEvent, activeNodeRect, transform }) => {
if (!activeNodeRect || !activatorEvent) return transform;
const activatorCoordinates = getEventCoordinates(activatorEvent);
if (!activatorCoordinates) return transform;
const rect = initialRect.current ?? activeNodeRect;
if (!initialRect.current) initialRect.current = activeNodeRect;
// Default offset so during drag the cursor doesn't overlap the overlay
// which can cause issues with mouse events on other elements
const offset = 12;
const offsetX = activatorCoordinates.x - rect.left;
const offsetY = activatorCoordinates.y - rect.top;
return {
...transform,
x: transform.x + offsetX + offset,
y: transform.y + offsetY + offset
};
};
useEffect(() => {
if (!explorerStore.drag) initialRect.current = null;
}, [explorerStore.drag]);
return modifier;
};
export const DragOverlay = memo(() => {
const isDark = useIsDark();
const modifier = useSnapToCursorModifier();
const { drag } = useExplorerStore();
return (
<DragOverlayPrimitive
dropAnimation={null}
modifiers={[modifier]}
className="!h-auto !w-full max-w-md"
>
{!drag || drag.type === 'touched' ? null : (
<div className="space-y-[2px] pl-0.5 pt-0.5 duration-300 animate-in fade-in">
{drag.items.length > 1 && (
<div className="absolute right-full top-1.5 mr-2 flex h-6 min-w-[24px] items-center justify-center rounded-full bg-accent px-1 text-sm text-white">
{drag.items.length}
</div>
)}
{(drag.items.slice(0, 8) as ExplorerItem[]).map((item, i, items) => (
<div
key={i}
className={clsx(
'flex items-center gap-2',
drag.items.length > 7 && [
i + 1 === items.length && 'opacity-10',
i + 2 === items.length && 'opacity-50',
i + 3 === items.length && 'opacity-90'
]
)}
>
<FileThumb
data={item}
size={32}
frame
frameClassName={clsx(
'!border-[1px] shadow-md',
isDark ? 'shadow-app-shade/50' : 'shadow-app-shade/25'
)}
/>
<RenamableItemText item={item} highlight={true} />
</div>
))}
</div>
)}
</DragOverlayPrimitive>
);
});

View file

@ -0,0 +1,28 @@
import { HTMLAttributes } from 'react';
import { useExplorerDraggable, UseExplorerDraggableProps } from './useExplorerDraggable';
/**
* Wrapper for explorer draggable items until dnd-kit solvers their re-rendering issues
* https://github.com/clauderic/dnd-kit/issues/1194#issuecomment-1696704815
*/
export const ExplorerDraggable = ({
draggable,
...props
}: Omit<HTMLAttributes<HTMLDivElement>, 'draggable'> & {
draggable: UseExplorerDraggableProps;
}) => {
const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable(draggable);
return (
<div
{...props}
ref={setDraggableRef}
style={{ ...props.style, ...style }}
{...attributes}
{...listeners}
>
{props.children}
</div>
);
};

View file

@ -0,0 +1,36 @@
import clsx from 'clsx';
import { createContext, HTMLAttributes, useContext, useMemo } from 'react';
import { useExplorerDroppable, UseExplorerDroppableProps } from './useExplorerDroppable';
const ExplorerDroppableContext = createContext<{ isDroppable: boolean } | null>(null);
export const useExplorerDroppableContext = () => {
const ctx = useContext(ExplorerDroppableContext);
if (ctx === null) throw new Error('ExplorerDroppableContext.Provider not found!');
return ctx;
};
/**
* Wrapper for explorer droppable items until dnd-kit solvers their re-rendering issues
* https://github.com/clauderic/dnd-kit/issues/1194#issuecomment-1696704815
*/
export const ExplorerDroppable = ({
droppable,
children,
...props
}: HTMLAttributes<HTMLDivElement> & { droppable: UseExplorerDroppableProps }) => {
const { isDroppable, className, setDroppableRef } = useExplorerDroppable(droppable);
const context = useMemo(() => ({ isDroppable }), [isDroppable]);
return (
<ExplorerDroppableContext.Provider value={context}>
<div {...props} ref={setDroppableRef} className={clsx(props.className, className)}>
{children}
</div>
</ExplorerDroppableContext.Provider>
);
};

View file

@ -0,0 +1,164 @@
import { CaretRight } from '@phosphor-icons/react';
import clsx from 'clsx';
import { memo, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { createSearchParams } from 'react-router-dom';
import { getExplorerItemData, getIndexedItemFilePath, useLibraryQuery } from '@sd/client';
import { Icon } from '~/components';
import { useIsDark, useOperatingSystem } from '~/hooks';
import { useExplorerContext } from './Context';
import { FileThumb } from './FilePath/Thumb';
import { useExplorerDroppable } from './useExplorerDroppable';
import { useExplorerSearchParams } from './util';
export const PATH_BAR_HEIGHT = 32;
export const ExplorerPath = memo(() => {
const os = useOperatingSystem();
const navigate = useNavigate();
const [{ path: searchPath }] = useExplorerSearchParams();
const { parent: explorerParent, selectedItems } = useExplorerContext();
const pathSlash = os === 'windows' ? '\\' : '/';
const location = explorerParent?.type === 'Location' ? explorerParent.location : undefined;
const selectedItem = useMemo(
() => (selectedItems.size === 1 ? [...selectedItems][0] : undefined),
[selectedItems]
);
const indexedFilePath = selectedItem && getIndexedItemFilePath(selectedItem);
const queryPath = !!indexedFilePath && (!searchPath || !location);
const { data: filePathname } = useLibraryQuery(['files.getPath', indexedFilePath?.id ?? -1], {
enabled: queryPath
});
const paths = useMemo(() => {
// Remove file name from the path
const _filePathname = filePathname?.slice(0, filePathname.lastIndexOf(pathSlash));
const pathname = _filePathname ?? [location?.path, searchPath].filter(Boolean).join('');
const paths = [...(pathname.match(new RegExp(`[^${pathSlash}]+`, 'g')) ?? [])];
let locationPath = location?.path;
if (!locationPath && indexedFilePath?.materialized_path) {
if (indexedFilePath.materialized_path === '/') locationPath = pathname;
else {
// Remove last slash from materialized_path
const materializedPath = indexedFilePath.materialized_path.slice(0, -1);
// Extract location path from pathname
locationPath = pathname.slice(0, pathname.indexOf(materializedPath));
}
}
const locationIndex = (locationPath ?? '').split(pathSlash).filter(Boolean).length - 1;
return paths.map((path, i) => {
const isLocation = locationIndex !== -1 && i >= locationIndex;
const _paths = [
...paths.slice(!isLocation ? 0 : locationIndex + 1, i),
i === locationIndex ? '' : path
];
let pathname = `${pathSlash}${_paths.join(pathSlash)}`;
// Add slash to the end of the pathname if it's a location
if (isLocation && i > locationIndex) pathname += pathSlash;
return {
name: path,
pathname,
locationId: isLocation ? indexedFilePath?.location_id ?? location?.id : undefined
};
});
}, [location, indexedFilePath, filePathname, pathSlash, searchPath]);
const handleOnClick = ({ pathname, locationId }: (typeof paths)[number]) => {
if (locationId === undefined) {
// TODO: Handle ephemeral volumes
navigate({
pathname: '../ephemeral/0-0',
search: `${createSearchParams({ path: pathname })}`
});
} else {
navigate({
pathname: `../location/${locationId}`,
search: pathname === '/' ? undefined : `${createSearchParams({ path: pathname })}`
});
}
};
return (
<div
className="group absolute inset-x-0 bottom-0 z-50 flex items-center border-t border-t-app-line bg-app/90 px-3.5 text-[11px] text-ink-dull backdrop-blur-lg"
style={{ height: PATH_BAR_HEIGHT }}
>
{paths.map((path) => (
<Path
key={path.pathname}
path={path}
onClick={() => handleOnClick(path)}
disabled={path.pathname === (searchPath ?? (location && '/'))}
/>
))}
{selectedItem && (!queryPath || filePathname) && (
<div className="ml-1 flex items-center gap-1">
<FileThumb data={selectedItem} size={16} frame frameClassName="!border" />
<span className="max-w-xs truncate">
{getExplorerItemData(selectedItem).fullName}
</span>
</div>
)}
</div>
);
});
interface PathProps {
path: { name: string; pathname: string; locationId?: number };
onClick: () => void;
disabled: boolean;
}
const Path = ({ path, onClick, disabled }: PathProps) => {
const isDark = useIsDark();
const { setDroppableRef, className, isDroppable } = useExplorerDroppable({
data: {
type: 'location',
path: path.pathname,
data: path.locationId ? { id: path.locationId, path: path.pathname } : undefined
},
allow: ['Path', 'NonIndexedPath', 'Object'],
navigateTo: onClick,
disabled
});
return (
<button
ref={setDroppableRef}
className={clsx(
'group flex items-center gap-1 rounded px-1 py-0.5',
isDroppable && [isDark ? 'bg-app-lightBox' : 'bg-app-darkerBox'],
!disabled && [isDark ? 'hover:bg-app-lightBox' : 'hover:bg-app-darkerBox'],
className
)}
disabled={disabled}
onClick={onClick}
tabIndex={-1}
>
<Icon name="Folder" size={16} alt="Folder" />
<span className="max-w-xs truncate text-ink-dull">{path.name}</span>
<CaretRight weight="bold" className="text-ink-dull group-last:hidden" size={10} />
</button>
);
};

View file

@ -1,6 +1,6 @@
import { getLayeredIcon } from '@sd/assets/util';
import clsx from 'clsx';
import { type ImgHTMLAttributes } from 'react';
import { forwardRef, type ImgHTMLAttributes } from 'react';
import { type ObjectKindKey } from '@sd/client';
interface LayeredFileIconProps extends ImgHTMLAttributes<HTMLImageElement> {
@ -16,28 +16,32 @@ const positionConfig: Record<string, string> = {
Config: 'flex h-full w-full items-center justify-center'
};
const LayeredFileIcon = ({ kind, extension, ...props }: LayeredFileIconProps) => {
const iconImg = <img {...props} />;
const LayeredFileIcon = forwardRef<HTMLImageElement, LayeredFileIconProps>(
({ kind, extension, ...props }, ref) => {
const iconImg = <img ref={ref} {...props} />;
if (SUPPORTED_ICONS.includes(kind) === false) {
return iconImg;
}
if (SUPPORTED_ICONS.includes(kind) === false) {
return iconImg;
}
const IconComponent = extension ? getLayeredIcon(kind, extension) : null;
const IconComponent = extension ? getLayeredIcon(kind, extension) : null;
const positionClass =
positionConfig[kind] || 'flex h-full w-full items-end justify-end pb-4 pr-2';
const positionClass =
positionConfig[kind] || 'flex h-full w-full items-end justify-end pb-4 pr-2';
return IconComponent == null ? (
iconImg
) : (
<div className="relative">
{iconImg}
<div className={clsx('absolute bottom-0 right-0', positionClass)}>
<IconComponent viewBox="0 0 16 16" height="40%" width="40%" />
return IconComponent == null ? (
iconImg
) : (
<div className="relative">
{iconImg}
<div
className={clsx('pointer-events-none absolute bottom-0 right-0', positionClass)}
>
<IconComponent viewBox="0 0 16 16" height="40%" width="40%" />
</div>
</div>
</div>
);
};
);
}
);
export default LayeredFileIcon;

View file

@ -182,7 +182,7 @@ interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {
blackBarsSize?: number;
}
const Video = memo(({ paused, blackBars, blackBarsSize, className, ...props }: VideoProps) => {
const Video = ({ paused, blackBars, blackBarsSize, className, ...props }: VideoProps) => {
const ref = useRef<HTMLVideoElement>(null);
const size = useSize(ref);
@ -220,4 +220,4 @@ const Video = memo(({ paused, blackBars, blackBarsSize, className, ...props }: V
<p>Video preview is not supported.</p>
</video>
);
});
};

View file

@ -1,31 +1,41 @@
import clsx from 'clsx';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react';
import TruncateMarkup from 'react-truncate-markup';
import { Tooltip } from '@sd/ui';
import { useOperatingSystem, useShortcut } from '~/hooks';
import { useExplorerViewContext } from '../ViewContext';
import { getExplorerStore, useExplorerStore } from '../store';
interface Props extends React.HTMLAttributes<HTMLDivElement> {
export interface RenameTextBoxProps extends React.HTMLAttributes<HTMLDivElement> {
name: string;
onRename: (newName: string) => void;
disabled?: boolean;
lines?: number;
// Temporary solution for TruncatedText in list view
idleClassName?: string;
}
export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
({ name, onRename, disabled, className, lines, ...props }, _ref) => {
const explorerView = useExplorerViewContext();
export const RenameTextBox = forwardRef<HTMLDivElement, RenameTextBoxProps>(
({ name, onRename, disabled, className, idleClassName, lines, ...props }, _ref) => {
const os = useOperatingSystem();
const explorerStore = useExplorerStore();
const [allowRename, setAllowRename] = useState(false);
const [isTruncated, setIsTruncated] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(_ref, () => ref.current);
const renamable = useRef<boolean>(false);
const timeout = useRef<NodeJS.Timeout | null>(null);
const ref = useRef<HTMLDivElement>(null);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(_ref, () => ref.current);
const [allowRename, setAllowRename] = useState(false);
const [isTruncated, setIsTruncated] = useState(false);
// Highlight file name up to extension or
// fully if it's a directory, hidden file or has no extension
@ -99,12 +109,6 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
}
};
const ellipsis = useCallback(() => {
const extension = name.lastIndexOf('.');
if (extension !== -1) return `...${name.slice(-(name.length - extension + 2))}`;
return `...${name.slice(-8)}`;
}, [name]);
useShortcut('renameObject', (e) => {
e.preventDefault();
if (allowRename) blur();
@ -128,10 +132,10 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
useEffect(() => {
if (!disabled) {
if (explorerView.isRenaming && !allowRename) setAllowRename(true);
else explorerView.setIsRenaming(allowRename);
if (explorerStore.isRenaming && !allowRename) setAllowRename(true);
else getExplorerStore().isRenaming = allowRename;
} else resetState();
}, [explorerView.isRenaming, disabled, allowRename, explorerView]);
}, [explorerStore.isRenaming, disabled, allowRename]);
useEffect(() => {
const onMouseDown = (event: MouseEvent) => {
@ -146,7 +150,11 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
<Tooltip
labelClassName="break-all"
tooltipClassName="!max-w-[250px]"
label={!isTruncated || allowRename ? null : name}
label={
!isTruncated || allowRename || explorerStore.drag?.type === 'dragging'
? null
: name
}
asChild
>
<div
@ -158,6 +166,7 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
className={clsx(
'cursor-default overflow-hidden rounded-md px-1.5 py-px text-xs text-ink outline-none',
allowRename && 'whitespace-normal bg-app !text-ink ring-2 ring-accent-deep',
!allowRename && idleClassName,
className
)}
onDoubleClick={(e) => {
@ -176,7 +185,7 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
onBlur={() => {
handleRename();
resetState();
explorerView.setIsRenaming(false);
getExplorerStore().isRenaming = false;
}}
onKeyDown={handleKeyDown}
{...props}
@ -184,16 +193,30 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
{allowRename ? (
name
) : (
<TruncateMarkup
lines={lines}
ellipsis={ellipsis}
onTruncate={setIsTruncated}
>
<div>{name}</div>
</TruncateMarkup>
<TruncatedText text={name} lines={lines} onTruncate={setIsTruncated} />
)}
</div>
</Tooltip>
);
}
);
interface TruncatedTextProps {
text: string;
lines?: number;
onTruncate: (wasTruncated: boolean) => void;
}
const TruncatedText = memo(({ text, lines, onTruncate }: TruncatedTextProps) => {
const ellipsis = useCallback(() => {
const extension = text.lastIndexOf('.');
if (extension !== -1) return `...${text.slice(-(text.length - extension + 2))}`;
return `...${text.slice(-8)}`;
}, [text]);
return (
<TruncateMarkup lines={lines} ellipsis={ellipsis} onTruncate={onTruncate}>
<div>{text}</div>
</TruncateMarkup>
);
});

View file

@ -1,13 +1,20 @@
import { getIcon, getIconByName } from '@sd/assets/util';
import clsx from 'clsx';
import { memo, SyntheticEvent, useMemo, useRef, useState, type ImgHTMLAttributes } from 'react';
import {
forwardRef,
HTMLAttributes,
SyntheticEvent,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { getItemFilePath, useLibraryContext, type ExplorerItem } from '@sd/client';
import { useIsDark } from '~/hooks';
import { pdfViewerEnabled } from '~/util/pdfViewer';
import { usePlatform } from '~/util/Platform';
import { useExplorerContext } from '../Context';
import { useExplorerItemData } from '../util';
import { Image, ImageProps } from './Image';
import LayeredFileIcon from './LayeredFileIcon';
@ -32,18 +39,18 @@ export interface ThumbProps {
frameClassName?: string;
childClassName?: string | ((type: ThumbType) => string | undefined);
isSidebarPreview?: boolean;
childProps?: HTMLAttributes<HTMLElement>;
}
type ThumbType = { variant: 'original' } | { variant: 'thumbnail' } | { variant: 'icon' };
export const FileThumb = memo((props: ThumbProps) => {
export const FileThumb = forwardRef<HTMLImageElement, ThumbProps>((props, ref) => {
const isDark = useIsDark();
const platform = usePlatform();
const itemData = useExplorerItemData(props.data);
const filePath = getItemFilePath(props.data);
const { parent } = useExplorerContext();
const { library } = useLibraryContext();
const [loadState, setLoadState] = useState<{
@ -72,14 +79,11 @@ export const FileThumb = memo((props: ThumbProps) => {
}, [itemData, loadState]);
const src = useMemo(() => {
const locationId =
itemData.locationId ?? (parent?.type === 'Location' ? parent.location.id : null);
switch (thumbType.variant) {
case 'original':
if (filePath && (itemData.extension !== 'pdf' || pdfViewerEnabled())) {
if ('id' in filePath && locationId)
return platform.getFileUrl(library.uuid, locationId, filePath.id);
if ('id' in filePath && itemData.locationId)
return platform.getFileUrl(library.uuid, itemData.locationId, filePath.id);
else if ('path' in filePath) return platform.getFileUrlByPath(filePath.path);
}
break;
@ -100,7 +104,7 @@ export const FileThumb = memo((props: ThumbProps) => {
itemData.isDir
);
}
}, [filePath, isDark, library.uuid, itemData, platform, thumbType, parent]);
}, [filePath, isDark, library.uuid, itemData, platform, thumbType]);
const onLoad = (s: 'original' | 'thumbnail' | 'icon') => {
setLoadState((state) => ({ ...state, [s]: 'loaded' }));
@ -133,12 +137,14 @@ export const FileThumb = memo((props: ThumbProps) => {
const className = clsx(childClassName, _childClassName);
const thumbnail = (() => {
if (!src) return null;
if (!src) return <></>;
switch (thumbType.variant) {
case 'thumbnail':
return (
<Thumbnail
{...props.childProps}
ref={ref}
src={src}
cover={props.cover}
onLoad={() => onLoad('thumbnail')}
@ -169,6 +175,8 @@ export const FileThumb = memo((props: ThumbProps) => {
case 'icon':
return (
<LayeredFileIcon
{...props.childProps}
ref={ref}
src={src}
kind={itemData.kind}
extension={itemData.extension}
@ -180,7 +188,7 @@ export const FileThumb = memo((props: ThumbProps) => {
/>
);
default:
return null;
return <></>;
}
})();
@ -233,17 +241,16 @@ interface ThumbnailProps extends Omit<ImageProps, 'blackBarsStyle' | 'size'> {
extension?: string;
}
const Thumbnail = memo(
({
crossOrigin,
blackBars,
blackBarsSize,
extension,
cover,
className,
...props
}: ThumbnailProps) => {
const Thumbnail = forwardRef<HTMLImageElement, ThumbnailProps>(
(
{ crossOrigin, blackBars, blackBarsSize, extension, cover, className, style, ...props },
_ref
) => {
const ref = useRef<HTMLImageElement>(null);
useImperativeHandle<HTMLImageElement | null, HTMLImageElement | null>(
_ref,
() => ref.current
);
const size = useSize(ref);
@ -259,7 +266,7 @@ const Thumbnail = memo(
blackBarsStyle && size.width === 0 && 'invisible'
),
cover,
style: blackBars ? blackBarsStyle : undefined,
style: { ...style, ...(blackBars ? blackBarsStyle : undefined) },
size,
ref
}}
@ -274,7 +281,7 @@ const Thumbnail = memo(
})
}}
className={clsx(
'absolute rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase text-white opacity-70',
'pointer-events-none 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'

View file

@ -400,7 +400,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
const { libraryId } = useZodRouteParams(LibraryIdParamsSchema);
const tagsQuery = useLibraryQuery(['tags.list'], {
enabled: readyToFetch && !explorerStore.isDragging,
enabled: readyToFetch && !explorerStore.isDragSelecting,
suspense: true
});
useNodes(tagsQuery.data?.nodes);
@ -408,7 +408,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
const tagsWithObjects = useLibraryQuery(
['tags.getWithObjects', selectedObjects.map(({ id }) => id)],
{ enabled: readyToFetch && !explorerStore.isDragging }
{ enabled: readyToFetch && !explorerStore.isDragSelecting }
);
const getDate = useCallback((metadataDate: MetadataDate, date: Date) => {

View file

@ -1,18 +1,10 @@
import { createContext, useContext, type ReactNode, type RefObject } from 'react';
import { ExplorerViewPadding } from './View';
export interface ExplorerViewContext {
ref: RefObject<HTMLDivElement>;
top?: number;
bottom?: number;
contextMenu?: ReactNode;
isContextMenuOpen?: boolean;
setIsContextMenuOpen?: (isOpen: boolean) => void;
isRenaming: boolean;
setIsRenaming: (isRenaming: boolean) => void;
padding?: Omit<ExplorerViewPadding, 'x' | 'y'>;
gap?: number | { x?: number; y?: number };
selectable: boolean;
listViewOptions?: {
hideHeaderBorder?: boolean;

View file

@ -0,0 +1,33 @@
import { useExplorerLayoutStore } from '@sd/client';
import { tw } from '@sd/ui';
import { useTopBarContext } from '../../TopBar/Layout';
import { useExplorerContext } from '../Context';
import { PATH_BAR_HEIGHT } from '../ExplorerPath';
import { useDragScrollable } from './useDragScrollable';
const Trigger = tw.div`absolute inset-x-0 h-10 pointer-events-none`;
export const DragScrollable = () => {
const topBar = useTopBarContext();
const explorer = useExplorerContext();
const explorerSettings = explorer.useSettingsSnapshot();
const layoutStore = useExplorerLayoutStore();
const showPathBar = explorer.showPathBar && layoutStore.showPathBar;
const { ref: dragScrollableUpRef } = useDragScrollable({ direction: 'up' });
const { ref: dragScrollableDownRef } = useDragScrollable({ direction: 'down' });
return (
<>
{explorerSettings.layoutMode !== 'list' && (
<Trigger ref={dragScrollableUpRef} style={{ top: topBar.topBarHeight }} />
)}
<Trigger
ref={dragScrollableDownRef}
style={{ bottom: showPathBar ? PATH_BAR_HEIGHT : 0 }}
/>
</>
);
};

View file

@ -0,0 +1,41 @@
import { Columns, GridFour, Icon, MonitorPlay, Rows } from '@phosphor-icons/react';
import { isValidElement, ReactNode } from 'react';
import { useExplorerContext } from '../Context';
export const EmptyNotice = (props: {
icon?: Icon | ReactNode;
message?: ReactNode;
loading?: boolean;
}) => {
const { layoutMode } = useExplorerContext().useSettingsSnapshot();
const emptyNoticeIcon = (icon?: Icon) => {
const Icon =
icon ??
{
grid: GridFour,
media: MonitorPlay,
columns: Columns,
list: Rows
}[layoutMode];
return <Icon size={100} opacity={0.3} />;
};
if (props.loading) return null;
return (
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
{props.icon
? isValidElement(props.icon)
? props.icon
: emptyNoticeIcon(props.icon as Icon)
: emptyNoticeIcon()}
<p className="mt-5 text-sm font-medium">
{props.message !== undefined ? props.message : 'This list is empty'}
</p>
</div>
);
};

View file

@ -1,255 +0,0 @@
import { CaretRight } from '@phosphor-icons/react';
import clsx from 'clsx';
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useMatch, useNavigate } from 'react-router';
import { ExplorerItem, FilePath, FilePathWithObject, useLibraryQuery } from '@sd/client';
import { LibraryIdParamsSchema, SearchParamsSchema } from '~/app/route-schemas';
import { Icon } from '~/components';
import { useOperatingSystem, useZodRouteParams, useZodSearchParams } from '~/hooks';
import { useExplorerContext } from '../Context';
import { FileThumb } from '../FilePath/Thumb';
import { useExplorerSearchParams } from '../util';
export const PATH_BAR_HEIGHT = 32;
export const ExplorerPath = memo(() => {
const isEphemeralLocation = useMatch('/:libraryId/ephemeral/:ephemeralId');
const os = useOperatingSystem();
const realOs = useOperatingSystem(true);
const navigate = useNavigate();
const libraryId = useZodRouteParams(LibraryIdParamsSchema).libraryId;
const pathSlashOS = os === 'browser' ? '/' : realOs === 'windows' ? '\\' : '/';
const firstRenderCached = useRef<null | boolean>(null);
const explorerContext = useExplorerContext();
const fullPathOnClick = explorerContext.parent?.type === 'Tag';
const [{ path }] = useExplorerSearchParams();
const [_, setSearchParams] = useZodSearchParams(SearchParamsSchema);
const selectedItem = useMemo(() => {
if (explorerContext.selectedItems.size !== 1) return;
return [...explorerContext.selectedItems][0];
}, [explorerContext.selectedItems]);
// On initial render, check if the location is nested
// If it is, return the number of times it is nested
const isLocationNested = useCallback(() => {
if (!explorerContext.parent || explorerContext.parent.type !== 'Location') return false;
firstRenderCached.current = true;
const { path: locationPath, name: locationName } = explorerContext.parent.location || {};
if (!locationPath || !locationName) return false;
const count = locationPath
.split(pathSlashOS)
.filter((part) => part === locationName).length;
return count > 1 ? count - 1 : false;
}, [explorerContext.parent, pathSlashOS]);
// On the first render of a location, check if the location is nested
useEffect(() => {
if (explorerContext.parent?.type === 'Location') {
isLocationNested();
}
return () => {
firstRenderCached.current = null;
};
}, [explorerContext.parent, isLocationNested]);
const filePathData = () => {
if (!selectedItem) return;
let filePathData: FilePath | FilePathWithObject | null = null;
const item = selectedItem as ExplorerItem;
switch (item.type) {
case 'Path': {
filePathData = item.item;
break;
}
case 'Object': {
filePathData = item.item.file_paths[0] ?? null;
break;
}
case 'SpacedropPeer': {
// objectData = item.item as unknown as Object;
// filePathData = item.item.file_paths[0] ?? null;
break;
}
}
return filePathData;
};
//this is being used with tag page route - when clicking on an object
//we get the full path of the object and use it to build the path bar
const queriedFullPath = useLibraryQuery(['files.getPath', filePathData()?.id ?? -1], {
enabled: selectedItem != null && fullPathOnClick
});
const indexedPath = fullPathOnClick
? queriedFullPath.data
: explorerContext.parent?.type === 'Location' && explorerContext.parent.location.path;
//There are cases where the path ends with a '/' and cases where it doesn't
const pathInfo = indexedPath
? indexedPath + (path ? path.slice(0, -1) : '')
: path?.endsWith(pathSlashOS)
? path?.slice(0, -1)
: path;
const pathBuilder = (pathsToSplit: string, clickedPath: string): string => {
const slashCheck = isEphemeralLocation ? pathSlashOS : '/'; //in ephemeral locations, the path is built with '\' instead of '/' for windows
const splitPaths = pathsToSplit?.split(slashCheck);
const indexOfClickedPath = splitPaths?.indexOf(clickedPath);
const newPath =
splitPaths?.slice(0, (indexOfClickedPath as number) + 1).join(slashCheck) + slashCheck;
return newPath;
};
const pathRedirectHandler = (pathName: string, index: number): void => {
let newPath: string | undefined;
if (fullPathOnClick) {
if (!explorerContext.selectedItems) return;
const objectData = Array.from(explorerContext.selectedItems)[0];
if (!objectData) return;
if ('file_paths' in objectData.item && objectData) {
newPath = pathBuilder(pathInfo as string, pathName);
navigate({
pathname: `/${libraryId}/ephemeral/0`,
search: `?path=${newPath}`
});
}
} else if (isEphemeralLocation) {
const currentPaths = data?.map((p) => p.name).join(pathSlashOS);
newPath = `${pathSlashOS}${pathBuilder(currentPaths as string, pathName)}`;
setSearchParams((params) => ({ ...params, path: newPath }));
} else {
newPath = pathBuilder(path as string, pathName);
setSearchParams((params) => ({ ...params, path: index === 0 ? '' : newPath }));
}
};
const pathNameLocationName =
explorerContext.parent?.type === 'Location' && explorerContext.parent?.location.name;
const data = useMemo(() => {
if (!pathInfo) return;
const splitPaths = pathInfo?.replaceAll('/', pathSlashOS).split(pathSlashOS); //replace all '/' with '\' for windows
//if the path is a full path
if (fullPathOnClick && queriedFullPath.data) {
if (!selectedItem) return;
const selectedItemFilePaths =
'file_paths' in selectedItem.item && selectedItem.item.file_paths[0];
if (!selectedItemFilePaths) return;
const updatedData = splitPaths
.map((path) => ({
kind: 'Folder',
extension: '',
name: path
}))
//remove duplicate path names upon selection + from the result of the full path query
.filter(
(path) =>
path.name !==
`${selectedItemFilePaths.name}.${selectedItemFilePaths.extension}` &&
path.name !== '' &&
path.name !== selectedItemFilePaths.name
);
return updatedData;
//handling ephemeral and location paths
} else {
let updatedPathData: string[] = [];
const nestedCount = isLocationNested();
const startIndex = isEphemeralLocation
? 1
: pathNameLocationName
? splitPaths.indexOf(pathNameLocationName)
: -1;
if (nestedCount) {
updatedPathData = splitPaths.slice(startIndex + nestedCount);
} else updatedPathData = splitPaths.slice(startIndex);
const updatedData = updatedPathData.map((path) => ({
kind: 'Folder',
extension: '',
name: path
}));
return updatedData;
}
}, [
pathInfo,
isLocationNested,
pathSlashOS,
isEphemeralLocation,
pathNameLocationName,
fullPathOnClick,
queriedFullPath.data,
selectedItem
]);
return (
<div
className="absolute inset-x-0 bottom-0 flex items-center gap-1 border-t border-t-app-line bg-app/90 px-3.5 text-[11px] text-ink-dull backdrop-blur-lg"
style={{ height: PATH_BAR_HEIGHT }}
>
{data?.map((p, index) => {
return (
<Path
key={(p.name + index).toString()}
paths={data.map((p) => p.name)}
path={p}
index={index}
fullPathOnClick={fullPathOnClick}
onClick={() => pathRedirectHandler(p.name, index)}
/>
);
})}
{selectedItem && (
<div className="pointer-events-none flex items-center gap-1">
{data && data.length > 0 && <CaretRight weight="bold" size={10} />}
<>
<FileThumb size={16} frame frameClassName="!border" data={selectedItem} />
{'name' in selectedItem.item ? (
<span className="max-w-xs truncate text-ink-dull">
{selectedItem.item.name}
</span>
) : (
<span className="max-w-xs truncate">
{selectedItem.item.file_paths[0]?.name}
</span>
)}
</>
</div>
)}
</div>
);
});
interface Props extends ComponentProps<'div'> {
paths: string[];
path: {
name: string;
};
fullPathOnClick: boolean;
index: number;
}
const Path = ({ paths, path, fullPathOnClick, index, ...rest }: Props) => {
return (
<div
className={clsx(
'flex items-center gap-1',
fullPathOnClick
? 'cursor-pointer text-ink-dull'
: index !== paths.length - 1 && ' cursor-pointer'
)}
{...rest}
>
<Icon name="Folder" size={16} alt="Folder" />
<span className="max-w-xs truncate text-ink-dull transition-opacity duration-300 hover:opacity-80">
{path.name}
</span>
{index !== (paths?.length as number) - 1 && (
<CaretRight weight="bold" className="text-ink-dull" size={10} />
)}
</div>
);
};

View file

@ -0,0 +1,86 @@
import { HTMLAttributes, useEffect, useMemo } from 'react';
import { type ExplorerItem } from '@sd/client';
import { RenderItem } from '.';
import { useExplorerContext } from '../../Context';
import { getExplorerStore, isCut } from '../../store';
import { uniqueId } from '../../util';
import { useExplorerViewContext } from '../Context';
import { useGridContext } from './context';
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
index: number;
item: ExplorerItem;
children: RenderItem;
}
export const GridItem = ({ children, item, ...props }: Props) => {
const grid = useGridContext();
const explorer = useExplorerContext();
const explorerView = useExplorerViewContext();
const explorerStore = getExplorerStore();
const itemId = useMemo(() => uniqueId(item), [item]);
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(item),
[explorer.selectedItems, item]
);
const cut = useMemo(
() => isCut(item, explorerStore.cutCopyState),
[explorerStore.cutCopyState, item]
);
useEffect(() => {
if (!grid.selecto?.current || !grid.selectoUnselected.current.has(itemId)) return;
if (!selected) {
grid.selectoUnselected.current.delete(itemId);
return;
}
const element = grid.getElementById(itemId);
if (!element) return;
grid.selectoUnselected.current.delete(itemId);
grid.selecto.current.setSelectedTargets([
...grid.selecto.current.getSelectedTargets(),
element as HTMLElement
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!grid.selecto) return;
return () => {
const element = grid.getElementById(itemId);
if (selected && !element) grid.selectoUnselected.current.add(itemId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected]);
return (
<div
{...props}
className="h-full w-full"
data-selectable=""
data-selectable-index={props.index}
data-selectable-id={itemId}
onContextMenu={(e) => {
if (explorerView.selectable && !explorer.selectedItems.has(item)) {
explorer.resetSelectedItems([item]);
grid.selecto?.current?.setSelectedTargets([e.currentTarget]);
}
}}
>
{children({ item: item, selected, cut })}
</div>
);
};

View file

@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
import Selecto from 'react-selecto';
interface GridContext {
selecto?: React.RefObject<Selecto>;
selectoUnselected: React.MutableRefObject<Set<string>>;
getElementById: (id: string) => Element | null | undefined;
}
export const GridContext = createContext<GridContext | null>(null);
export const useGridContext = () => {
const ctx = useContext(GridContext);
if (ctx === null) throw new Error('GridContext.Provider not found!');
return ctx;
};

View file

@ -1,138 +1,63 @@
import { Grid, useGrid } from '@virtual-grid/react';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode
} from 'react';
import { memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import Selecto from 'react-selecto';
import { type ExplorerItem } from '@sd/client';
import { useOperatingSystem, useShortcut } from '~/hooks';
import { useExplorerContext } from '../Context';
import { getQuickPreviewStore, useQuickPreviewStore } from '../QuickPreview/store';
import { getExplorerStore, isCut, useExplorerStore } from '../store';
import { uniqueId } from '../util';
import { useExplorerViewContext } from '../ViewContext';
import { useExplorerContext } from '../../Context';
import { getQuickPreviewStore, useQuickPreviewStore } from '../../QuickPreview/store';
import { getExplorerStore } from '../../store';
import { uniqueId } from '../../util';
import { useExplorerViewContext } from '../Context';
import { GridContext } from './context';
import { GridItem } from './Item';
const SelectoContext = createContext<{
selecto: React.RefObject<Selecto>;
selectoUnSelected: React.MutableRefObject<Set<string>>;
} | null>(null);
type RenderItem = (item: { item: ExplorerItem; selected: boolean; cut: boolean }) => ReactNode;
const GridListItem = (props: {
index: number;
export type RenderItem = (item: {
item: ExplorerItem;
children: RenderItem;
onMouseDown: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
getElementById: (id: string) => Element | null | undefined;
}) => {
const explorer = useExplorerContext();
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
const selecto = useContext(SelectoContext);
const cut = isCut(props.item, explorerStore.cutCopyState);
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 itemId = uniqueId(props.item);
useEffect(() => {
if (!selecto?.selecto.current || !selecto.selectoUnSelected.current.has(itemId)) return;
if (!selected) {
selecto.selectoUnSelected.current.delete(itemId);
return;
}
const element = props.getElementById(itemId);
if (!element) return;
selecto.selectoUnSelected.current.delete(itemId);
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 = props.getElementById(itemId);
if (selected && !element) selecto.selectoUnSelected.current.add(itemId);
};
// 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={itemId}
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>
);
};
selected: boolean;
cut: boolean;
}) => ReactNode;
const CHROME_REGEX = /Chrome/;
export default ({ children }: { children: RenderItem }) => {
export default memo(({ children }: { children: RenderItem }) => {
const os = useOperatingSystem();
const realOS = useOperatingSystem(true);
const isChrome = CHROME_REGEX.test(navigator.userAgent);
const explorer = useExplorerContext();
const settings = explorer.useSettingsSnapshot();
const explorerView = useExplorerViewContext();
const explorerSettings = explorer.useSettingsSnapshot();
const quickPreviewStore = useQuickPreviewStore();
const selecto = useRef<Selecto>(null);
const selectoUnSelected = useRef<Set<string>>(new Set());
const selectoUnselected = useRef<Set<string>>(new Set());
const selectoFirstColumn = useRef<number | undefined>();
const selectoLastColumn = useRef<number | undefined>();
// 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);
const [dragFromThumbnail, setDragFromThumbnail] = useState(false);
const itemDetailsHeight = 44 + (settings.showBytesInGridView ? 20 : 0);
const itemHeight = settings.gridItemSize + itemDetailsHeight;
const padding = settings.layoutMode === 'grid' ? 12 : 0;
const itemDetailsHeight = 44 + (explorerSettings.showBytesInGridView ? 20 : 0);
const itemHeight = explorerSettings.gridItemSize + itemDetailsHeight;
const padding = explorerSettings.layoutMode === 'grid' ? 12 : 0;
const grid = useGrid({
scrollRef: explorer.scrollRef,
count: explorer.items?.length ?? 0,
totalCount: explorer.count,
...(settings.layoutMode === 'grid'
? { columns: 'auto', size: { width: settings.gridItemSize, height: itemHeight } }
: { columns: settings.mediaColumns }),
...(explorerSettings.layoutMode === 'grid'
? {
columns: 'auto',
size: { width: explorerSettings.gridItemSize, height: itemHeight }
}
: { columns: explorerSettings.mediaColumns }),
rowVirtualizer: { overscan: explorer.overscan ?? 5 },
onLoadMore: explorer.loadMore,
getItemId: useCallback(
@ -144,14 +69,11 @@ export default ({ children }: { children: RenderItem }) => {
),
getItemData: useCallback((index: number) => explorer.items?.[index], [explorer.items]),
padding: {
...explorerView.padding,
bottom: explorerView.bottom
? (explorerView.padding?.bottom ?? padding) + explorerView.bottom
: undefined,
bottom: explorerView.bottom ? padding + explorerView.bottom : undefined,
x: padding,
y: padding
},
gap: explorerView.gap || (settings.layoutMode === 'grid' ? settings.gridGap : undefined)
gap: explorerSettings.layoutMode === 'grid' ? explorerSettings.gridGap : 1
});
const getElementById = useCallback(
@ -201,6 +123,16 @@ export default ({ children }: { children: RenderItem }) => {
return activeItem;
}
function handleDragEnd() {
getExplorerStore().isDragSelecting = false;
selectoFirstColumn.current = undefined;
selectoLastColumn.current = undefined;
setDragFromThumbnail(false);
const allSelected = selecto.current?.getSelectedTargets() ?? [];
activeItem.current = getActiveItem(allSelected);
}
useEffect(
() => {
const element = explorer.scrollRef.current;
@ -234,7 +166,7 @@ export default ({ children }: { children: RenderItem }) => {
return selected;
});
selectoUnSelected.current = set;
selectoUnselected.current = set;
selecto.current.setSelectedTargets(items as HTMLElement[]);
activeItem.current = getActiveItem(items);
@ -242,16 +174,10 @@ export default ({ children }: { children: RenderItem }) => {
// 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();
selectoUnselected.current = new Set();
// Accessing refs during render is bad
activeItem.current = null;
}, [explorer.selectedItems]);
@ -289,7 +215,7 @@ export default ({ children }: { children: RenderItem }) => {
} else {
explorer.resetSelectedItems([newSelectedItem.data]);
selecto.current?.setSelectedTargets([selectedItemElement as HTMLElement]);
if (selectoUnSelected.current.size > 0) selectoUnSelected.current = new Set();
if (selectoUnselected.current.size > 0) selectoUnselected.current = new Set();
}
}
@ -414,14 +340,14 @@ export default ({ children }: { children: RenderItem }) => {
const element = getElementById(itemId);
if (!element) selectoUnSelected.current = new Set(itemId);
if (!element) selectoUnselected.current = new Set(itemId);
else selecto.current.setSelectedTargets([element as HTMLElement]);
activeItem.current = item;
}, [explorer.items, explorer.selectedItems, quickPreviewStore.open, realOS, getElementById]);
return (
<SelectoContext.Provider value={selecto.current ? { selecto, selectoUnSelected } : null}>
<GridContext.Provider value={{ selecto, selectoUnselected, getElementById }}>
{explorer.allowMultiSelect && (
<Selecto
ref={selecto}
@ -437,22 +363,19 @@ export default ({ children }: { children: RenderItem }) => {
selectableTargets={['[data-selectable]']}
toggleContinueSelect="shift"
hitRate={0}
// selectFromInside={explorerStore.layoutMode === 'media'}
onDragStart={(e) => {
getExplorerStore().isDragging = true;
if ((e.inputEvent as MouseEvent).target instanceof HTMLImageElement) {
onDrag={(e) => {
if (!getExplorerStore().drag) return;
e.stop();
handleDragEnd();
}}
onDragStart={({ inputEvent }) => {
getExplorerStore().isDragSelecting = true;
if ((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() ?? [];
activeItem.current = getActiveItem(allSelected);
}}
onDragEnd={handleDragEnd}
onScroll={({ direction }) => {
selecto.current?.findSelectableTargets();
explorer.scrollRef.current?.scrollBy(
@ -482,7 +405,7 @@ export default ({ children }: { children: RenderItem }) => {
if (explorer.selectedItems.has(item.data)) {
selecto.current?.setSelectedTargets(e.beforeSelected);
} else {
selectoUnSelected.current = new Set();
selectoUnselected.current = new Set();
explorer.resetSelectedItems([item.data]);
}
@ -657,8 +580,8 @@ export default ({ children }: { children: RenderItem }) => {
}
if (unselectedItems.length > 0) {
selectoUnSelected.current = new Set([
...selectoUnSelected.current,
selectoUnselected.current = new Set([
...selectoUnselected.current,
...unselectedItems
]);
}
@ -673,11 +596,10 @@ export default ({ children }: { children: RenderItem }) => {
if (!item) return null;
return (
<GridListItem
<GridItem
key={uniqueId(item)}
index={index}
item={item}
getElementById={getElementById}
onMouseDown={(e) => {
if (e.button !== 0 || !explorerView.selectable) return;
@ -698,10 +620,10 @@ export default ({ children }: { children: RenderItem }) => {
}}
>
{children}
</GridListItem>
</GridItem>
);
}}
</Grid>
</SelectoContext.Provider>
</GridContext.Provider>
);
};
});

View file

@ -1,89 +0,0 @@
import clsx from 'clsx';
import { memo } from 'react';
import { useMatch } from 'react-router';
import {
byteSize,
getExplorerItemData,
getItemFilePath,
getItemLocation,
type ExplorerItem
} from '@sd/client';
import { useExplorerContext } from '../Context';
import { FileThumb } from '../FilePath/Thumb';
import { useExplorerViewContext } from '../ViewContext';
import GridList from './GridList';
import { RenamableItemText } from './RenamableItemText';
import { ViewItem } from './ViewItem';
interface GridViewItemProps {
data: ExplorerItem;
selected: boolean;
isRenaming: boolean;
cut: boolean;
}
const GridViewItem = memo(({ data, selected, cut, isRenaming }: GridViewItemProps) => {
const explorer = useExplorerContext();
const { showBytesInGridView } = explorer.useSettingsSnapshot();
const explorerItemData = getExplorerItemData(data);
const filePathData = getItemFilePath(data);
const location = getItemLocation(data);
const isEphemeralLocation = useMatch('/:libraryId/ephemeral/:ephemeralId');
const isFolder = filePathData?.is_dir;
const hidden = filePathData?.hidden;
const showSize =
showBytesInGridView &&
!location &&
!isFolder &&
(!isEphemeralLocation || !isFolder) &&
(!isRenaming || !selected);
return (
<ViewItem data={data} className={clsx('h-full w-full', hidden && 'opacity-50')}>
<div
className={clsx('mb-1 aspect-square rounded-lg', selected && 'bg-app-selectedItem')}
>
<FileThumb
data={data}
frame={explorerItemData.kind !== 'Video'}
extension
blackBars
className={clsx('px-2 py-1', cut && 'opacity-60')}
/>
</div>
<div className="flex flex-col justify-center">
<RenamableItemText item={data} style={{ maxHeight: 40 }} lines={2} />
{showSize && filePathData?.size_in_bytes_bytes && (
<span
className={clsx(
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull'
)}
>
{`${byteSize(filePathData.size_in_bytes_bytes)}`}
</span>
)}
</div>
</ViewItem>
);
});
export default () => {
const explorerView = useExplorerViewContext();
return (
<GridList>
{({ item, selected, cut }) => (
<GridViewItem
data={item}
selected={selected}
cut={cut}
isRenaming={explorerView.isRenaming}
/>
)}
</GridList>
);
};

View file

@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';
import { GridViewItemProps } from '.';
export const GridViewItemContext = createContext<GridViewItemProps | null>(null);
export const useGridViewItemContext = () => {
const ctx = useContext(GridViewItemContext);
if (ctx === null) throw new Error('GridViewItemContext.Provider not found!');
return ctx;
};

View file

@ -0,0 +1,128 @@
import clsx from 'clsx';
import { memo, useMemo } from 'react';
import { byteSize, getItemFilePath, type ExplorerItem } from '@sd/client';
import { useExplorerContext } from '../../../Context';
import { ExplorerDraggable } from '../../../ExplorerDraggable';
import { ExplorerDroppable, useExplorerDroppableContext } from '../../../ExplorerDroppable';
import { FileThumb } from '../../../FilePath/Thumb';
import { useExplorerStore } from '../../../store';
import { useExplorerDraggable } from '../../../useExplorerDraggable';
import { RenamableItemText } from '../../RenamableItemText';
import { ViewItem } from '../../ViewItem';
import { GridViewItemContext, useGridViewItemContext } from './Context';
export interface GridViewItemProps {
data: ExplorerItem;
selected: boolean;
cut: boolean;
}
export const GridViewItem = memo((props: GridViewItemProps) => {
const filePath = getItemFilePath(props.data);
const isHidden = filePath?.hidden;
const isFolder = filePath?.is_dir;
const isLocation = props.data.type === 'Location';
return (
<GridViewItemContext.Provider value={props}>
<ViewItem data={props.data} className={clsx('h-full w-full', isHidden && 'opacity-50')}>
<ExplorerDroppable
droppable={{
data: { type: 'explorer-item', data: props.data },
disabled: (!isFolder && !isLocation) || props.selected
}}
>
<InnerDroppable />
</ExplorerDroppable>
</ViewItem>
</GridViewItemContext.Provider>
);
});
const InnerDroppable = () => {
const item = useGridViewItemContext();
const { isDroppable } = useExplorerDroppableContext();
return (
<>
<div
className={clsx(
'mb-1 aspect-square rounded-lg',
(item.selected || isDroppable) && 'bg-app-selectedItem'
)}
>
<ItemFileThumb />
</div>
<ExplorerDraggable draggable={{ data: item.data }}>
<RenamableItemText
item={item.data}
style={{ maxHeight: 40, textAlign: 'center' }}
lines={2}
highlight={isDroppable}
selected={item.selected}
/>
<ItemSize />
</ExplorerDraggable>
</>
);
};
const ItemFileThumb = () => {
const item = useGridViewItemContext();
const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable({
data: item.data
});
return (
<FileThumb
data={item.data}
frame
blackBars
extension
className={clsx('px-2 py-1', item.cut && 'opacity-60')}
ref={setDraggableRef}
childProps={{
style,
...attributes,
...listeners
}}
/>
);
};
const ItemSize = () => {
const item = useGridViewItemContext();
const { showBytesInGridView } = useExplorerContext().useSettingsSnapshot();
const { isRenaming } = useExplorerStore();
const filePath = getItemFilePath(item.data);
const isLocation = item.data.type === 'Location';
const isEphemeral = item.data.type === 'NonIndexedPath';
const isFolder = filePath?.is_dir;
const showSize =
showBytesInGridView &&
filePath?.size_in_bytes_bytes &&
!isLocation &&
!isFolder &&
(!isEphemeral || !isFolder) &&
(!isRenaming || !item.selected);
const bytes = useMemo(
() => showSize && byteSize(filePath?.size_in_bytes_bytes),
[filePath?.size_in_bytes_bytes, showSize]
);
if (!showSize) return null;
return (
<div className="truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull">
{`${bytes}`}
</div>
);
};

View file

@ -0,0 +1,12 @@
import Grid from '../Grid';
import { GridViewItem } from './Item';
export const GridView = () => {
return (
<Grid>
{({ item, selected, cut }) => (
<GridViewItem data={item} selected={selected} cut={cut} />
)}
</Grid>
);
};

View file

@ -0,0 +1,84 @@
import { flexRender, type Cell } from '@tanstack/react-table';
import clsx from 'clsx';
import { memo, useMemo } from 'react';
import { getItemFilePath, type ExplorerItem } from '@sd/client';
import { TABLE_PADDING_X } from '.';
import { ExplorerDraggable } from '../../ExplorerDraggable';
import { ExplorerDroppable, useExplorerDroppableContext } from '../../ExplorerDroppable';
import { ViewItem } from '../ViewItem';
import { useTableContext } from './context';
interface Props {
data: ExplorerItem;
selected: boolean;
cells: Cell<ExplorerItem, unknown>[];
}
export const ListViewItem = memo(({ data, selected, cells }: Props) => {
const filePath = getItemFilePath(data);
return (
<ViewItem
data={data}
className="flex h-full"
style={{ paddingLeft: TABLE_PADDING_X, paddingRight: TABLE_PADDING_X }}
>
<ExplorerDroppable
className="relative"
droppable={{
data: { type: 'explorer-item', data },
disabled: (!filePath?.is_dir && data.type !== 'Location') || selected
}}
>
<DroppableOverlay />
<ExplorerDraggable
draggable={{ data }}
className={clsx('flex h-full items-center', filePath?.hidden && 'opacity-50')}
>
{cells.map((cell) => (
<Cell key={cell.id} cell={cell} selected={selected} />
))}
</ExplorerDraggable>
</ExplorerDroppable>
</ViewItem>
);
});
const DroppableOverlay = () => {
const { isDroppable } = useExplorerDroppableContext();
if (!isDroppable) return null;
return <div className="absolute inset-0 rounded-md bg-accent/25" />;
};
const Cell = ({ cell, selected }: { cell: Cell<ExplorerItem, unknown>; selected: boolean }) => {
useTableContext(); // Force re-render for column sizing
return <InnerCell cell={cell} size={cell.column.getSize()} selected={selected} />;
};
const InnerCell = memo(
(props: { cell: Cell<ExplorerItem, unknown>; size: number; selected: boolean }) => {
const value = useMemo(() => props.cell.getValue(), [props.cell]);
return (
<div
key={props.cell.id}
className={clsx(
'table-cell px-4 text-xs text-ink-dull',
props.cell.column.id !== 'name' && 'truncate',
props.cell.column.columnDef.meta?.className
)}
style={{ width: props.size }}
>
{value
? `${value}`
: flexRender(props.cell.column.columnDef.cell, {
...props.cell.getContext(),
selected: props.selected
})}
</div>
);
}
);

View file

@ -0,0 +1,57 @@
import { type Row } from '@tanstack/react-table';
import clsx from 'clsx';
import { useMemo } from 'react';
import { type ExplorerItem } from '@sd/client';
import { TABLE_PADDING_X } from '.';
import { useExplorerContext } from '../../Context';
import { ListViewItem } from './Item';
interface Props {
row: Row<ExplorerItem>;
previousRow?: Row<ExplorerItem>;
nextRow?: Row<ExplorerItem>;
}
export const TableRow = ({ row, previousRow, nextRow }: Props) => {
const explorer = useExplorerContext();
const selected = useMemo(() => {
return explorer.selectedItems.has(row.original);
}, [explorer.selectedItems, row.original]);
const isPreviousRowSelected = useMemo(() => {
if (!previousRow) return;
return explorer.selectedItems.has(previousRow.original);
}, [explorer.selectedItems, previousRow]);
const isNextRowSelected = useMemo(() => {
if (!nextRow) return;
return explorer.selectedItems.has(nextRow.original);
}, [explorer.selectedItems, nextRow]);
const cells = row.getVisibleCells();
return (
<>
<div
className={clsx(
'absolute inset-0 rounded-md border',
row.index % 2 === 0 && 'bg-app-darkBox',
selected ? 'border-accent !bg-accent/10' : 'border-transparent',
selected && [
isPreviousRowSelected && 'rounded-t-none border-t-0 border-t-transparent',
isNextRowSelected && 'rounded-b-none border-b-0 border-b-transparent'
]
)}
style={{ left: TABLE_PADDING_X, right: TABLE_PADDING_X }}
>
{isPreviousRowSelected && (
<div className="absolute inset-x-3 top-0 h-px bg-accent/10" />
)}
</div>
<ListViewItem data={row.original} selected={selected} cells={cells} />
</>
);
};

View file

@ -0,0 +1,16 @@
import { ColumnSizingState } from '@tanstack/react-table';
import { createContext, useContext } from 'react';
interface TableContext {
columnSizing: ColumnSizingState;
}
export const TableContext = createContext<TableContext | null>(null);
export const useTableContext = () => {
const ctx = useContext(TableContext);
if (ctx === null) throw new Error('TableContext.Provider not found!');
return ctx;
};

View file

@ -1,112 +1,46 @@
import { CaretDown, CaretUp } from '@phosphor-icons/react';
import {
flexRender,
VisibilityState,
type ColumnSizingState,
type Row
} from '@tanstack/react-table';
import { flexRender, type ColumnSizingState, type Row } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import BasicSticky from 'react-sticky-el';
import { useWindowEventListener } from 'rooks';
import useResizeObserver from 'use-resize-observer';
import { getItemFilePath, type ExplorerItem } from '@sd/client';
import { ContextMenu, Tooltip } from '@sd/ui';
import { useIsTextTruncated, useShortcut } from '~/hooks';
import { type ExplorerItem } from '@sd/client';
import { ContextMenu } from '@sd/ui';
import { TruncatedText } from '~/components';
import { useShortcut } from '~/hooks';
import { isNonEmptyObject } from '~/util';
import { useLayoutContext } from '../../../Layout/Context';
import { useExplorerContext } from '../../Context';
import { getQuickPreviewStore, useQuickPreviewStore } from '../../QuickPreview/store';
import {
createOrdering,
getOrderingDirection,
isCut,
orderingKey,
useExplorerStore
} from '../../store';
import { createOrdering, getOrderingDirection, orderingKey } from '../../store';
import { uniqueId } from '../../util';
import { useExplorerViewContext } from '../../ViewContext';
import { useExplorerViewPadding } from '../util';
import { ViewItem } from '../ViewItem';
import { getRangeDirection, Range, useRanges } from './util/ranges';
import { useTable } from './util/table';
interface ListViewItemProps {
row: Row<ExplorerItem>;
paddingLeft: number;
paddingRight: number;
// Props below are passed to trigger a rerender
// TODO: Find a better solution
columnSizing: ColumnSizingState;
columnVisibility: VisibilityState;
isCut: boolean;
}
const ListViewItem = memo((props: ListViewItemProps) => {
const filePathData = getItemFilePath(props.row.original);
const hidden = filePathData?.hidden ?? false;
return (
<ViewItem
data={props.row.original}
className="relative flex h-full items-center"
style={{ paddingLeft: props.paddingLeft, paddingRight: props.paddingRight }}
>
{props.row.getVisibleCells().map((cell) => (
<div
role="cell"
key={cell.id}
className={clsx(
'table-cell shrink-0 px-4 text-xs text-ink-dull',
cell.column.id !== 'name' && 'truncate',
cell.column.columnDef.meta?.className,
hidden && 'opacity-50'
)}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</ViewItem>
);
});
const HeaderColumnName = ({ name }: { name: string }) => {
const textRef = useRef<HTMLParagraphElement>(null);
const isTruncated = useIsTextTruncated(textRef);
return (
<div ref={textRef} className="truncate">
{isTruncated ? (
<Tooltip label={name}>
<span className="truncate">{name}</span>
</Tooltip>
) : (
<span>{name}</span>
)}
</div>
);
};
import { useExplorerViewContext } from '../Context';
import { useDragScrollable } from '../useDragScrollable';
import { TableContext } from './context';
import { TableRow } from './TableRow';
import { getRangeDirection, Range, useRanges } from './useRanges';
import { useTable } from './useTable';
const ROW_HEIGHT = 45;
const PADDING_X = 16;
const PADDING_Y = 12;
export const TABLE_PADDING_X = 16;
export const TABLE_PADDING_Y = 12;
export default () => {
export const ListView = memo(() => {
const layout = useLayoutContext();
const explorer = useExplorerContext();
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
const settings = explorer.useSettingsSnapshot();
const explorerSettings = explorer.useSettingsSnapshot();
const quickPreview = useQuickPreviewStore();
const tableRef = useRef<HTMLDivElement>(null);
const tableHeaderRef = useRef<HTMLDivElement>(null);
const tableHeaderRef = useRef<HTMLDivElement | null>(null);
const tableBodyRef = useRef<HTMLDivElement>(null);
const { ref: scrollableRef } = useDragScrollable({ direction: 'up' });
const [sized, setSized] = useState(false);
const [initialized, setInitialized] = useState(false);
const [locked, setLocked] = useState(false);
@ -125,23 +59,12 @@ export default () => {
rows: rowsById
});
const viewPadding = useExplorerViewPadding(explorerView.padding);
const padding = {
top: viewPadding.top ?? PADDING_Y,
bottom: viewPadding.bottom ?? PADDING_Y,
left: viewPadding.left ?? PADDING_X,
right: viewPadding.right ?? PADDING_X
};
const count = !explorer.count ? rows.length : Math.max(rows.length, explorer.count);
const rowVirtualizer = useVirtualizer({
count: count,
count: !explorer.count ? rows.length : Math.max(rows.length, explorer.count),
getScrollElement: useCallback(() => explorer.scrollRef.current, [explorer.scrollRef]),
estimateSize: useCallback(() => ROW_HEIGHT, []),
paddingStart: padding.top,
paddingEnd: padding.bottom + (explorerView.bottom ?? 0),
paddingStart: TABLE_PADDING_Y,
paddingEnd: TABLE_PADDING_Y + (explorerView.bottom ?? 0),
scrollMargin: listOffset,
overscan: explorer.overscan ?? 10
});
@ -424,7 +347,7 @@ export default () => {
}
};
function handleRowContextMenu(row: Row<ExplorerItem>) {
const handleRowContextMenu = (row: Row<ExplorerItem>) => {
if (explorerView.contextMenu === undefined) return;
const item = row.original;
@ -434,7 +357,7 @@ export default () => {
const hash = uniqueId(item);
setRanges([[hash, hash]]);
}
}
};
const scrollToRow = useCallback(
(row: Row<ExplorerItem>) => {
@ -457,14 +380,14 @@ export default () => {
const rowBottom = rowTop + ROW_HEIGHT;
if (rowTop < tableTop) {
const scrollBy = rowTop - tableTop - (row.index === 0 ? padding.top : 0);
const scrollBy = rowTop - tableTop - (row.index === 0 ? TABLE_PADDING_Y : 0);
explorer.scrollRef.current.scrollBy({ top: scrollBy });
} else if (rowBottom > scrollRect.height - (explorerView.bottom ?? 0)) {
const scrollBy =
rowBottom -
scrollRect.height +
(explorerView.bottom ?? 0) +
(row.index === rows.length - 1 ? padding.bottom : 0);
(row.index === rows.length - 1 ? TABLE_PADDING_Y : 0);
explorer.scrollRef.current.scrollBy({ top: scrollBy });
}
@ -473,139 +396,12 @@ export default () => {
explorer.scrollRef,
explorerView.bottom,
explorerView.top,
padding.bottom,
padding.top,
rowVirtualizer.options.paddingStart,
rows.length,
top
]
);
useEffect(() => setRanges([]), [settings.order]);
useEffect(() => {
if (!getQuickPreviewStore().open || explorer.selectedItems.size !== 1) return;
const [item] = [...explorer.selectedItems];
if (!item) return;
const itemId = uniqueId(item);
setRanges([[itemId, itemId]]);
}, [explorer.selectedItems]);
useEffect(() => {
if (initialized || !sized || !explorer.count || explorer.selectedItems.size === 0) {
if (explorer.selectedItems.size === 0 && !initialized) setInitialized(true);
return;
}
const rows = [...explorer.selectedItems]
.reduce((rows, item) => {
const row = rowsById[uniqueId(item)];
if (row) rows.push(row);
return rows;
}, [] as Row<ExplorerItem>[])
.sort((a, b) => a.index - b.index);
const lastRow = rows[rows.length - 1];
if (!lastRow) return;
scrollToRow(lastRow);
setRanges(rows.map((row) => [uniqueId(row.original), uniqueId(row.original)] as Range));
setInitialized(true);
}, [explorer.count, explorer.selectedItems, initialized, rowsById, scrollToRow, sized]);
// Measure initial column widths
useEffect(() => {
if (
!tableRef.current ||
sized ||
!isNonEmptyObject(columnSizing) ||
!isNonEmptyObject(columnVisibility)
) {
return;
}
const sizing = table
.getVisibleLeafColumns()
.reduce(
(sizing, column) => ({ ...sizing, [column.id]: column.getSize() }),
{} as ColumnSizingState
);
const tableWidth = tableRef.current.offsetWidth;
const columnsWidth =
Object.values(sizing).reduce((a, b) => a + b, 0) + (padding.left + padding.right);
if (columnsWidth < tableWidth) {
const nameWidth = (sizing.name ?? 0) + (tableWidth - columnsWidth);
table.setColumnSizing({ ...sizing, name: nameWidth });
setLocked(true);
} else if (columnsWidth > tableWidth) {
const nameColSize = sizing.name ?? 0;
const minNameColSize = table.getColumn('name')?.columnDef.minSize;
const difference = columnsWidth - tableWidth;
if (minNameColSize !== undefined && nameColSize - difference >= minNameColSize) {
table.setColumnSizing({ ...sizing, name: nameColSize - difference });
setLocked(true);
}
} else if (columnsWidth === tableWidth) {
setLocked(true);
}
setSized(true);
}, [columnSizing, columnVisibility, padding.left, padding.right, sized, table]);
// Load more items
useEffect(() => {
if (!explorer.loadMore) return;
const lastRow = virtualRows[virtualRows.length - 1];
if (!lastRow) return;
const loadMoreFromRow = Math.ceil(rows.length * 0.75);
if (lastRow.index >= loadMoreFromRow - 1) explorer.loadMore.call(undefined);
}, [virtualRows, rows.length, explorer.loadMore]);
// Sync scroll
useEffect(() => {
const table = tableRef.current;
const header = tableHeaderRef.current;
const body = tableBodyRef.current;
if (!table || !header || !body || quickPreview.open) return;
const handleWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return;
event.preventDefault();
header.scrollLeft += event.deltaX;
body.scrollLeft += event.deltaX;
};
const handleScroll = (element: HTMLDivElement) => {
if (isLeftMouseDown) return;
// Sorting sometimes resets scrollLeft
// so we reset it here in case it does
// to keep the scroll in sync
// TODO: Find a better solution
header.scrollLeft = element.scrollLeft;
body.scrollLeft = element.scrollLeft;
};
table.addEventListener('wheel', handleWheel);
header.addEventListener('scroll', () => handleScroll(header));
body.addEventListener('scroll', () => handleScroll(body));
return () => {
table.removeEventListener('wheel', handleWheel);
header.addEventListener('scroll', () => handleScroll(header));
body.addEventListener('scroll', () => handleScroll(body));
};
}, [sized, isLeftMouseDown]);
const keyboardHandler = (e: KeyboardEvent, direction: 'ArrowDown' | 'ArrowUp') => {
if (!explorerView.selectable) return;
@ -760,6 +556,130 @@ export default () => {
scrollToRow(nextRow);
};
useEffect(() => setRanges([]), [explorerSettings.order]);
useEffect(() => {
if (!getQuickPreviewStore().open || explorer.selectedItems.size !== 1) return;
const [item] = [...explorer.selectedItems];
if (!item) return;
const itemId = uniqueId(item);
setRanges([[itemId, itemId]]);
}, [explorer.selectedItems]);
useEffect(() => {
if (initialized || !sized || !explorer.count || explorer.selectedItems.size === 0) {
if (explorer.selectedItems.size === 0 && !initialized) setInitialized(true);
return;
}
const rows = [...explorer.selectedItems]
.reduce((rows, item) => {
const row = rowsById[uniqueId(item)];
if (row) rows.push(row);
return rows;
}, [] as Row<ExplorerItem>[])
.sort((a, b) => a.index - b.index);
const lastRow = rows[rows.length - 1];
if (!lastRow) return;
scrollToRow(lastRow);
setRanges(rows.map((row) => [uniqueId(row.original), uniqueId(row.original)] as Range));
setInitialized(true);
}, [explorer.count, explorer.selectedItems, initialized, rowsById, scrollToRow, sized]);
// Measure initial column widths
useEffect(() => {
if (
!tableRef.current ||
sized ||
!isNonEmptyObject(columnSizing) ||
!isNonEmptyObject(columnVisibility)
) {
return;
}
const sizing = table
.getVisibleLeafColumns()
.reduce(
(sizing, column) => ({ ...sizing, [column.id]: column.getSize() }),
{} as ColumnSizingState
);
const tableWidth = tableRef.current.offsetWidth;
const columnsWidth = Object.values(sizing).reduce((a, b) => a + b, 0) + TABLE_PADDING_X * 2;
if (columnsWidth < tableWidth) {
const nameWidth = (sizing.name ?? 0) + (tableWidth - columnsWidth);
table.setColumnSizing({ ...sizing, name: nameWidth });
setLocked(true);
} else if (columnsWidth > tableWidth) {
const nameColSize = sizing.name ?? 0;
const minNameColSize = table.getColumn('name')?.columnDef.minSize;
const difference = columnsWidth - tableWidth;
if (minNameColSize !== undefined && nameColSize - difference >= minNameColSize) {
table.setColumnSizing({ ...sizing, name: nameColSize - difference });
setLocked(true);
}
} else if (columnsWidth === tableWidth) {
setLocked(true);
}
setSized(true);
}, [columnSizing, columnVisibility, sized, table]);
// Load more items
useEffect(() => {
if (!explorer.loadMore) return;
const lastRow = virtualRows[virtualRows.length - 1];
if (!lastRow) return;
const loadMoreFromRow = Math.ceil(rows.length * 0.75);
if (lastRow.index >= loadMoreFromRow - 1) explorer.loadMore.call(undefined);
}, [virtualRows, rows.length, explorer.loadMore]);
// Sync scroll
useEffect(() => {
const table = tableRef.current;
const header = tableHeaderRef.current;
const body = tableBodyRef.current;
if (!table || !header || !body || quickPreview.open) return;
const handleWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return;
event.preventDefault();
header.scrollLeft += event.deltaX;
body.scrollLeft += event.deltaX;
};
const handleScroll = (element: HTMLDivElement) => {
if (isLeftMouseDown) return;
// Sorting sometimes resets scrollLeft
// so we reset it here in case it does
// to keep the scroll in sync
// TODO: Find a better solution
header.scrollLeft = element.scrollLeft;
body.scrollLeft = element.scrollLeft;
};
table.addEventListener('wheel', handleWheel);
header.addEventListener('scroll', () => handleScroll(header));
body.addEventListener('scroll', () => handleScroll(body));
return () => {
table.removeEventListener('wheel', handleWheel);
header.addEventListener('scroll', () => handleScroll(header));
body.addEventListener('scroll', () => handleScroll(body));
};
}, [sized, isLeftMouseDown, quickPreview.open]);
useShortcut('explorerEscape', () => {
if (!explorerView.selectable || explorer.selectedItems.size === 0) return;
explorer.resetSelectedItems([]);
@ -799,7 +719,7 @@ export default () => {
);
const columnsWidth =
Object.values(sizing).reduce((a, b) => a + b, 0) + (padding.left + padding.right);
Object.values(sizing).reduce((a, b) => a + b, 0) + TABLE_PADDING_X * 2;
if (locked) {
const newNameSize = (sizing.name ?? 0) + (width - columnsWidth);
@ -843,263 +763,237 @@ export default () => {
useLayoutEffect(() => setListOffset(tableRef.current?.offsetTop ?? 0), []);
return (
<div
ref={tableRef}
onMouseDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
setIsLeftMouseDown(true);
}}
className={clsx(!initialized && 'invisible')}
>
{sized && (
<>
<BasicSticky
scrollElement={explorer.scrollRef.current ?? undefined}
stickyStyle={{ top, zIndex: 10 }}
topOffset={-top}
// Without this the width of the element doesn't get updated
// when the inspector is toggled
positionRecheckInterval={100}
>
<ContextMenu.Root
trigger={
<div
ref={tableHeaderRef}
className={clsx(
'top-bar-blur !border-sidebar-divider bg-app/90',
explorerView.listViewOptions?.hideHeaderBorder
? 'border-b'
: 'border-y',
// Prevent drag scroll
isLeftMouseDown
? 'overflow-hidden'
: 'no-scrollbar overflow-x-auto overscroll-x-none'
)}
>
{table.getHeaderGroups().map((headerGroup) => (
<div key={headerGroup.id} className="flex w-fit">
{headerGroup.headers.map((header, i) => {
const size = header.column.getSize();
const orderKey =
settings.order && orderingKey(settings.order);
const orderingDirection =
orderKey &&
settings.order &&
(orderKey.startsWith('object.')
? orderKey.split('object.')[1] === header.id
: orderKey === header.id)
? getOrderingDirection(settings.order)
: null;
const cellContent = flexRender(
header.column.columnDef.header,
header.getContext()
);
return (
<div
key={header.id}
className={clsx(
'relative flex items-center justify-between gap-3 px-4 py-2 text-xs first:pl-[83px]',
orderingDirection !== null
? 'text-ink'
: 'text-ink-dull'
)}
style={{
width:
i === 0 ||
i === headerGroup.headers.length - 1
? size +
padding[i ? 'right' : 'left']
: size
}}
onClick={() => {
if (resizing) return;
// Split table into smaller parts
// cause this looks hideous
const orderKey =
explorer.orderingKeys?.options.find(
(o) => {
if (
typeof o.value !==
'string'
)
return;
const value =
o.value as string;
return value.startsWith(
'object.'
)
? value.split(
'object.'
)[1] === header.id
: value === header.id;
}
);
if (!orderKey) return;
explorer.settingsStore.order =
createOrdering(
orderKey.value,
orderingDirection === 'Asc'
? 'Desc'
: 'Asc'
);
}}
>
{header.isPlaceholder ? null : (
<>
{typeof cellContent === 'string' ? (
<HeaderColumnName
name={cellContent}
/>
) : (
cellContent
)}
{orderingDirection === 'Asc' && (
<CaretUp className="shrink-0 text-ink-faint" />
)}
{orderingDirection === 'Desc' && (
<CaretDown className="shrink-0 text-ink-faint" />
)}
<div
onMouseDown={(e) => {
setResizing(true);
setLocked(false);
header.getResizeHandler()(
e
);
if (layout.ref.current) {
layout.ref.current.style.cursor =
'col-resize';
}
}}
onTouchStart={header.getResizeHandler()}
className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-sidebar-divider"
/>
</>
)}
</div>
);
})}
</div>
))}
</div>
}
<TableContext.Provider value={{ columnSizing }}>
<div
ref={tableRef}
onMouseDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
setIsLeftMouseDown(true);
}}
className={clsx(!initialized && 'invisible')}
>
{sized && (
<>
<BasicSticky
scrollElement={explorer.scrollRef.current ?? undefined}
stickyStyle={{ top, zIndex: 10 }}
topOffset={-top}
// Without this the width of the element doesn't get updated
// when the inspector is toggled
positionRecheckInterval={100}
>
{table.getAllLeafColumns().map((column) => {
if (column.id === 'name') return null;
return (
<ContextMenu.CheckboxItem
key={column.id}
checked={column.getIsVisible()}
onSelect={column.getToggleVisibilityHandler()}
label={
typeof column.columnDef.header === 'string'
? column.columnDef.header
: column.id
}
/>
);
})}
</ContextMenu.Root>
</BasicSticky>
<div
ref={tableBodyRef}
className={clsx(
// Prevent drag scroll
isLeftMouseDown
? 'overflow-hidden'
: 'no-scrollbar overflow-x-auto overscroll-x-none'
)}
>
<div
className="relative"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
const selected = explorer.isItemSelected(row.original);
const cut = isCut(row.original, explorerStore.cutCopyState);
const previousRow = rows[virtualRow.index - 1];
const nextRow = rows[virtualRow.index + 1];
const selectedPrior =
previousRow && explorer.isItemSelected(previousRow.original);
const selectedNext =
nextRow && explorer.isItemSelected(nextRow.original);
return (
<ContextMenu.Root
trigger={
<div
key={row.id}
className="absolute left-0 top-0 min-w-full"
style={{
height: virtualRow.size,
transform: `translateY(${
virtualRow.start -
rowVirtualizer.options.scrollMargin
}px)`
ref={(element) => {
tableHeaderRef.current = element;
scrollableRef(element);
}}
onMouseDown={(e) => handleRowClick(e, row)}
onContextMenu={() => handleRowContextMenu(row)}
className={clsx(
'top-bar-blur !border-sidebar-divider bg-app/90',
explorerView.listViewOptions?.hideHeaderBorder
? 'border-b'
: 'border-y',
// Prevent drag scroll
isLeftMouseDown
? 'overflow-hidden'
: 'no-scrollbar overflow-x-auto overscroll-x-none'
)}
>
<div
className={clsx(
'absolute inset-0 rounded-md border',
virtualRow.index % 2 === 0 && 'bg-app-darkBox',
selected
? 'border-accent !bg-accent/10'
: 'border-transparent',
selected &&
selectedPrior &&
'rounded-t-none border-t-0 border-t-transparent',
selected &&
selectedNext &&
'rounded-b-none border-b-0 border-b-transparent'
)}
style={{
right: padding.right,
left: padding.left
}}
>
{selectedPrior && (
<div className="absolute inset-x-3 top-0 h-px bg-accent/10" />
)}
</div>
{table.getHeaderGroups().map((headerGroup) => (
<div key={headerGroup.id} className="flex w-fit">
{headerGroup.headers.map((header, i) => {
const size = header.column.getSize();
<ListViewItem
row={row}
paddingLeft={padding.left}
paddingRight={padding.right}
columnSizing={columnSizing}
columnVisibility={columnVisibility}
isCut={cut}
/>
const orderKey =
explorerSettings.order &&
orderingKey(explorerSettings.order);
const orderingDirection =
orderKey &&
explorerSettings.order &&
(orderKey.startsWith('object.')
? orderKey.split('object.')[1] ===
header.id
: orderKey === header.id)
? getOrderingDirection(
explorerSettings.order
)
: null;
const cellContent = flexRender(
header.column.columnDef.header,
header.getContext()
);
return (
<div
key={header.id}
className={clsx(
'relative flex items-center justify-between gap-3 px-4 py-2 text-xs first:pl-[83px]',
orderingDirection !== null
? 'text-ink'
: 'text-ink-dull'
)}
style={{
width:
i === 0 ||
i ===
headerGroup.headers.length -
1
? size + TABLE_PADDING_X
: size
}}
onClick={() => {
if (resizing) return;
// Split table into smaller parts
// cause this looks hideous
const orderKey =
explorer.orderingKeys?.options.find(
(o) => {
if (
typeof o.value !==
'string'
)
return;
const value =
o.value as string;
return value.startsWith(
'object.'
)
? value.split(
'object.'
)[1] === header.id
: value ===
header.id;
}
);
if (!orderKey) return;
explorer.settingsStore.order =
createOrdering(
orderKey.value,
orderingDirection === 'Asc'
? 'Desc'
: 'Asc'
);
}}
>
{header.isPlaceholder ? null : (
<>
<TruncatedText>
{cellContent}
</TruncatedText>
{orderingDirection ===
'Asc' && (
<CaretUp className="shrink-0 text-ink-faint" />
)}
{orderingDirection ===
'Desc' && (
<CaretDown className="shrink-0 text-ink-faint" />
)}
<div
onMouseDown={(e) => {
setResizing(true);
setLocked(false);
header.getResizeHandler()(
e
);
if (
layout.ref.current
) {
layout.ref.current.style.cursor =
'col-resize';
}
}}
onTouchStart={header.getResizeHandler()}
className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-sidebar-divider"
/>
</>
)}
</div>
);
})}
</div>
))}
</div>
);
})}
}
>
{table.getAllLeafColumns().map((column) => {
if (column.id === 'name') return null;
return (
<ContextMenu.CheckboxItem
key={column.id}
checked={column.getIsVisible()}
onSelect={column.getToggleVisibilityHandler()}
label={
typeof column.columnDef.header === 'string'
? column.columnDef.header
: column.id
}
/>
);
})}
</ContextMenu.Root>
</BasicSticky>
<div
ref={tableBodyRef}
className={clsx(
// Prevent drag scroll
isLeftMouseDown
? 'overflow-hidden'
: 'no-scrollbar overflow-x-auto overscroll-x-none'
)}
>
<div
className="relative"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
const previousRow = rows[virtualRow.index - 1];
const nextRow = rows[virtualRow.index + 1];
return (
<div
key={row.id}
className="absolute left-0 top-0 min-w-full"
style={{
height: virtualRow.size,
transform: `translateY(${
virtualRow.start -
rowVirtualizer.options.scrollMargin
}px)`
}}
onMouseDown={(e) => handleRowClick(e, row)}
onContextMenu={() => handleRowContextMenu(row)}
>
<TableRow
row={row}
previousRow={previousRow}
nextRow={nextRow}
/>
</div>
);
})}
</div>
</div>
</div>
</>
)}
</div>
</>
)}
</div>
</TableContext.Provider>
);
};
});

View file

@ -1,4 +1,5 @@
import {
CellContext,
getCoreRowModel,
useReactTable,
type ColumnDef,
@ -7,7 +8,7 @@ import {
} from '@tanstack/react-table';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { stringify } from 'uuid';
import {
byteSize,
@ -19,16 +20,44 @@ import {
} from '@sd/client';
import { isNonEmptyObject } from '~/util';
import { useExplorerContext } from '../../../Context';
import { FileThumb } from '../../../FilePath/Thumb';
import { InfoPill } from '../../../Inspector';
import { isCut, useExplorerStore } from '../../../store';
import { uniqueId } from '../../../util';
import { RenamableItemText } from '../../RenamableItemText';
import { useExplorerContext } from '../../Context';
import { FileThumb } from '../../FilePath/Thumb';
import { InfoPill } from '../../Inspector';
import { CutCopyState, isCut, useExplorerStore } from '../../store';
import { uniqueId } from '../../util';
import { RenamableItemText } from '../RenamableItemText';
const NameCell = memo(({ item, selected }: { item: ExplorerItem; selected: boolean }) => {
const { cutCopyState } = useExplorerStore();
const cut = useMemo(() => isCut(item, cutCopyState as CutCopyState), [cutCopyState, item]);
return (
<div className="relative flex items-center">
<FileThumb
data={item}
frame
frameClassName="!border"
blackBars
size={35}
className={clsx('mr-2.5', cut && 'opacity-60')}
/>
<RenamableItemText
item={item}
selected={selected}
allowHighlight={false}
style={{ maxHeight: 36 }}
idleClassName="w-full !max-h-5"
/>
</div>
);
});
type Cell = CellContext<ExplorerItem, unknown> & { selected?: boolean };
export const useTable = () => {
const explorer = useExplorerContext();
const explorerStore = useExplorerStore();
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
@ -40,29 +69,9 @@ export const useTable = () => {
header: 'Name',
minSize: 200,
maxSize: undefined,
cell: ({ row }) => {
const item = row.original;
const cut = isCut(item, explorerStore.cutCopyState);
return (
<div className="relative flex items-center">
<FileThumb
data={item}
frame
frameClassName="!border"
blackBars
size={35}
className={clsx('mr-2.5', cut && 'opacity-60')}
/>
<RenamableItemText
item={item}
allowHighlight={false}
style={{ maxHeight: 36 }}
/>
</div>
);
}
cell: ({ row, selected }: Cell) => (
<NameCell item={row.original} selected={!!selected} />
)
},
{
id: 'kind',
@ -132,7 +141,7 @@ export const useTable = () => {
}
}
],
[explorerStore.cutCopyState]
[]
);
const table = useReactTable({

View file

@ -1,67 +0,0 @@
import { ArrowsOutSimple } from '@phosphor-icons/react';
import clsx from 'clsx';
import { memo } from 'react';
import { ExplorerItem, getItemFilePath } from '@sd/client';
import { Button } from '@sd/ui';
import { useExplorerContext } from '../Context';
import { FileThumb } from '../FilePath/Thumb';
import { getQuickPreviewStore } from '../QuickPreview/store';
import GridList from './GridList';
import { ViewItem } from './ViewItem';
interface MediaViewItemProps {
data: ExplorerItem;
selected: boolean;
cut: boolean;
}
const MediaViewItem = memo(({ data, selected, cut }: MediaViewItemProps) => {
const settings = useExplorerContext().useSettingsSnapshot();
const filePathData = getItemFilePath(data);
const hidden = filePathData?.hidden ?? false;
return (
<ViewItem
data={data}
className={clsx(
'h-full w-full overflow-hidden border-2',
selected ? 'border-accent' : 'border-transparent',
hidden && 'opacity-50'
)}
>
<div
className={clsx(
'group relative flex aspect-square items-center justify-center hover:bg-app-selectedItem',
selected && 'bg-app-selectedItem'
)}
>
<FileThumb
data={data}
cover={settings.mediaAspectSquare}
extension
className={clsx(!settings.mediaAspectSquare && 'px-1', cut && 'opacity-60')}
/>
<Button
variant="gray"
size="icon"
className="absolute right-2 top-2 hidden rounded-full shadow group-hover:block"
onClick={() => (getQuickPreviewStore().open = true)}
>
<ArrowsOutSimple />
</Button>
</div>
</ViewItem>
);
});
export default () => {
return (
<GridList>
{({ item, selected, cut }) => (
<MediaViewItem data={item} selected={selected} cut={cut} />
)}
</GridList>
);
};

View file

@ -0,0 +1,67 @@
import { ArrowsOutSimple } from '@phosphor-icons/react';
import clsx from 'clsx';
import { memo } from 'react';
import { ExplorerItem, getItemFilePath } from '@sd/client';
import { Button } from '@sd/ui';
import { FileThumb } from '../../FilePath/Thumb';
import { getQuickPreviewStore } from '../../QuickPreview/store';
import { useExplorerDraggable } from '../../useExplorerDraggable';
import { ViewItem } from '../ViewItem';
interface Props {
data: ExplorerItem;
selected: boolean;
cut: boolean;
cover: boolean;
}
export const MediaViewItem = memo(({ data, selected, cut, cover }: Props) => {
return (
<ViewItem
data={data}
className={clsx(
'group relative h-full w-full border-2 hover:bg-app-selectedItem',
selected ? 'border-accent bg-app-selectedItem' : 'border-transparent'
)}
>
<ItemFileThumb data={data} cut={cut} cover={cover} />
<Button
variant="gray"
size="icon"
className="absolute right-1 top-1 hidden !rounded shadow group-hover:block"
onClick={() => (getQuickPreviewStore().open = true)}
>
<ArrowsOutSimple />
</Button>
</ViewItem>
);
});
const ItemFileThumb = (props: Pick<Props, 'data' | 'cut' | 'cover'>) => {
const filePath = getItemFilePath(props.data);
const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable({
data: props.data
});
return (
<FileThumb
data={props.data}
cover={props.cover}
extension
className={clsx(
!props.cover && 'p-0.5',
props.cut && 'opacity-60',
filePath?.hidden && 'opacity-50'
)}
ref={setDraggableRef}
childProps={{
style,
...attributes,
...listeners
}}
/>
);
};

View file

@ -0,0 +1,20 @@
import { useExplorerContext } from '../../Context';
import Grid from '../Grid';
import { MediaViewItem } from './Item';
export const MediaView = () => {
const explorerSettings = useExplorerContext().useSettingsSnapshot();
return (
<Grid>
{({ item, selected, cut }) => (
<MediaViewItem
data={item}
selected={selected}
cut={cut}
cover={explorerSettings.mediaAspectSquare}
/>
)}
</Grid>
);
};

View file

@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useMemo, useRef } from 'react';
import { useRef } from 'react';
import {
getEphemeralPath,
getExplorerItemData,
@ -12,31 +12,31 @@ import { toast } from '@sd/ui';
import { useIsDark } from '~/hooks';
import { useExplorerContext } from '../Context';
import { RenameTextBox } from '../FilePath/RenameTextBox';
import { RenameTextBox, RenameTextBoxProps } from '../FilePath/RenameTextBox';
import { useQuickPreviewStore } from '../QuickPreview/store';
import { useExplorerStore } from '../store';
interface Props {
interface Props extends Pick<RenameTextBoxProps, 'idleClassName' | 'lines'> {
item: ExplorerItem;
allowHighlight?: boolean;
style?: React.CSSProperties;
lines?: number;
highlight?: boolean;
selected?: boolean;
}
export const RenamableItemText = ({ item, allowHighlight = true, style, lines }: Props) => {
const rspc = useRspcLibraryContext();
const explorer = useExplorerContext();
const quickPreviewStore = useQuickPreviewStore();
export const RenamableItemText = ({ allowHighlight = true, ...props }: Props) => {
const isDark = useIsDark();
const rspc = useRspcLibraryContext();
const itemData = getExplorerItemData(item);
const explorer = useExplorerContext({ suspense: false });
const explorerStore = useExplorerStore();
const quickPreviewStore = useQuickPreviewStore();
const itemData = getExplorerItemData(props.item);
const ref = useRef<HTMLDivElement>(null);
const selected = useMemo(
() => explorer.selectedItems.has(item),
[explorer.selectedItems, item]
);
const renameFile = useLibraryMutation(['files.renameFile'], {
onError: () => reset(),
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
@ -59,9 +59,9 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
const handleRename = async (newName: string) => {
try {
switch (item.type) {
switch (props.item.type) {
case 'Location': {
const locationId = item.item.id;
const locationId = props.item.item.id;
if (!locationId) throw new Error('Missing location id');
await renameLocation.mutateAsync({
@ -79,7 +79,7 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
case 'Path':
case 'Object': {
const filePathData = getIndexedItemFilePath(item);
const filePathData = getIndexedItemFilePath(props.item);
if (!filePathData) throw new Error('Failed to get file path object');
@ -101,7 +101,7 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
}
case 'NonIndexedPath': {
const ephemeralFile = getEphemeralPath(item);
const ephemeralFile = getEphemeralPath(props.item);
if (!ephemeralFile) throw new Error('Failed to get ephemeral file object');
@ -130,10 +130,12 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
};
const disabled =
!selected ||
!props.selected ||
explorerStore.drag?.type === 'dragging' ||
!explorer ||
explorer.selectedItems.size > 1 ||
quickPreviewStore.open ||
item.type === 'SpacedropPeer';
props.item.type === 'SpacedropPeer';
return (
<RenameTextBox
@ -141,11 +143,13 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
disabled={disabled}
onRename={handleRename}
className={clsx(
'text-center font-medium',
selected && allowHighlight && ['bg-accent', !isDark && 'text-white']
'font-medium',
(props.selected || props.highlight) &&
allowHighlight && ['bg-accent', !isDark && 'text-white']
)}
style={style}
lines={lines}
style={props.style}
lines={props.lines}
idleClassName={props.idleClassName}
/>
);
};

View file

@ -1,4 +1,4 @@
import { memo, useCallback, type HTMLAttributes, type PropsWithChildren } from 'react';
import { useCallback, type HTMLAttributes, type PropsWithChildren } from 'react';
import { createSearchParams, useNavigate } from 'react-router-dom';
import {
isPath,
@ -15,8 +15,9 @@ import { usePlatform } from '~/util/Platform';
import { useExplorerContext } from '../Context';
import { getQuickPreviewStore } from '../QuickPreview/store';
import { getExplorerStore } from '../store';
import { uniqueId } from '../util';
import { useExplorerViewContext } from '../ViewContext';
import { useExplorerViewContext } from './Context';
export const useViewItemDoubleClick = () => {
const navigate = useNavigate();
@ -58,7 +59,7 @@ export const useViewItemDoubleClick = () => {
: selectedItem.item.file_paths;
for (const filePath of paths) {
if (isPath(selectedItem) && selectedItem.item.is_dir) {
if (filePath.is_dir) {
items.dirs.splice(sameAsClicked ? 0 : -1, 0, filePath);
} else {
items.paths.splice(sameAsClicked ? 0 : -1, 0, filePath);
@ -176,7 +177,7 @@ interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement
data: ExplorerItem;
}
export const ViewItem = memo(({ data, children, ...props }: ViewItemProps) => {
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
const explorerView = useExplorerViewContext();
const { doubleClick } = useViewItemDoubleClick();
@ -184,15 +185,15 @@ export const ViewItem = memo(({ data, children, ...props }: ViewItemProps) => {
return (
<ContextMenu.Root
trigger={
<div onDoubleClick={() => doubleClick(data)} {...props}>
<div {...props} onDoubleClick={() => doubleClick(data)}>
{children}
</div>
}
onOpenChange={explorerView.setIsContextMenuOpen}
onOpenChange={(open) => (getExplorerStore().isContextMenuOpen = open)}
disabled={explorerView.contextMenu === undefined}
onMouseDown={(e) => e.stopPropagation()}
>
{explorerView.contextMenu}
</ContextMenu.Root>
);
});
};

View file

@ -1,25 +1,10 @@
import { Columns, GridFour, MonitorPlay, Rows, type Icon } from '@phosphor-icons/react';
import clsx from 'clsx';
import {
isValidElement,
memo,
useCallback,
useEffect,
useRef,
useState,
type ReactNode
} from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
ExplorerLayout,
getExplorerLayoutStore,
getItemObject,
useExplorerLayoutStore,
type Object
} from '@sd/client';
import { dialogManager, ModifierKeys } from '@sd/ui';
import { useKeys } from 'rooks';
import { ExplorerLayout, getExplorerLayoutStore, getItemObject, type Object } from '@sd/client';
import { dialogManager } from '@sd/ui';
import { Loader } from '~/components';
import { useKeyCopyCutPaste, useOperatingSystem, useShortcut } from '~/hooks';
import { useKeyCopyCutPaste, useKeyMatcher, useShortcut } from '~/hooks';
import { isNonEmpty } from '~/util';
import CreateDialog from '../../settings/library/tags/CreateDialog';
@ -27,226 +12,196 @@ import { useExplorerContext } from '../Context';
import { QuickPreview } from '../QuickPreview';
import { useQuickPreviewContext } from '../QuickPreview/Context';
import { getQuickPreviewStore, useQuickPreviewStore } from '../QuickPreview/store';
import { ViewContext, type ExplorerViewContext } from '../ViewContext';
import GridView from './GridView';
import ListView from './ListView';
import MediaView from './MediaView';
import { useExplorerViewPadding } from './util';
import { getExplorerStore, useExplorerStore } from '../store';
import { useExplorerDroppable } from '../useExplorerDroppable';
import { useExplorerSearchParams } from '../util';
import { ViewContext, type ExplorerViewContext } from './Context';
import { DragScrollable } from './DragScrollable';
import { GridView } from './GridView';
import { ListView } from './ListView';
import { MediaView } from './MediaView';
import { useViewItemDoubleClick } from './ViewItem';
export interface ExplorerViewPadding {
x?: number;
y?: number;
top?: number;
bottom?: number;
left?: number;
right?: number;
}
export interface ExplorerViewProps
extends Omit<
ExplorerViewContext,
| 'selectable'
| 'isRenaming'
| 'isContextMenuOpen'
| 'setIsRenaming'
| 'setIsContextMenuOpen'
| 'ref'
| 'padding'
> {
className?: string;
style?: React.CSSProperties;
extends Omit<ExplorerViewContext, 'selectable' | 'ref' | 'padding'> {
emptyNotice?: JSX.Element;
padding?: number | ExplorerViewPadding;
}
export default memo(
({ className, style, emptyNotice, padding, ...contextProps }: ExplorerViewProps) => {
const explorer = useExplorerContext();
const quickPreview = useQuickPreviewContext();
const quickPreviewStore = useQuickPreviewStore();
const layoutStore = useExplorerLayoutStore();
const { doubleClick } = useViewItemDoubleClick();
export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
const explorer = useExplorerContext();
const explorerStore = useExplorerStore();
const { layoutMode } = explorer.useSettingsSnapshot();
const { layoutMode } = explorer.useSettingsSnapshot();
const quickPreview = useQuickPreviewContext();
const quickPreviewStore = useQuickPreviewStore();
const ref = useRef<HTMLDivElement>(null);
const [{ path }] = useExplorerSearchParams();
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [showLoading, setShowLoading] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
const viewPadding = useExplorerViewPadding(padding);
const [showLoading, setShowLoading] = useState(false);
useKeyDownHandlers({
disabled: isRenaming || quickPreviewStore.open
});
const selectable =
explorer.selectable &&
!explorerStore.isContextMenuOpen &&
!explorerStore.isRenaming &&
!quickPreviewStore.open;
useEffect(() => {
if (!isContextMenuOpen || explorer.selectedItems.size !== 0) return;
// Close context menu when no items are selected
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
setIsContextMenuOpen(false);
}, [explorer.selectedItems, isContextMenuOpen]);
// Can stay here until we add columns view
// Once added, the provided parent related logic should move to useExplorerDroppable
// that way we don't have to re-use the same logic for each view
const { setDroppableRef } = useExplorerDroppable({
...(explorer.parent?.type === 'Location' && {
allow: ['Path', 'NonIndexedPath'],
data: { type: 'location', path: path ?? '/', data: explorer.parent.location },
disabled:
explorerStore.drag?.type === 'dragging' &&
explorer.parent.location.id === explorerStore.drag.sourceLocationId &&
(path ?? '/') === explorerStore.drag.sourcePath
}),
...(explorer.parent?.type === 'Ephemeral' && {
allow: ['Path', 'NonIndexedPath'],
data: { type: 'location', path: explorer.parent.path },
disabled:
explorerStore.drag?.type === 'dragging' &&
explorer.parent.path === explorerStore.drag.sourcePath
}),
...(explorer.parent?.type === 'Tag' && {
allow: 'Path',
data: { type: 'tag', data: explorer.parent.tag },
disabled:
explorerStore.drag?.type === 'dragging' &&
explorer.parent.tag.id === explorerStore.drag.sourceTagId
})
});
useEffect(() => {
if (explorer.isFetchingNextPage) {
const timer = setTimeout(() => setShowLoading(true), 100);
return () => clearTimeout(timer);
} else setShowLoading(false);
}, [explorer.isFetchingNextPage]);
useShortcuts();
useEffect(() => {
if (explorer.layouts[layoutMode]) return;
// If the current layout mode is not available, switch to the first available layout mode
const layout = (Object.keys(explorer.layouts) as ExplorerLayout[]).find(
(key) => explorer.layouts[key]
);
explorer.settingsStore.layoutMode = layout ?? 'grid';
}, [layoutMode, explorer.layouts, explorer.settingsStore]);
useEffect(() => {
if (!explorerStore.isContextMenuOpen || explorer.selectedItems.size !== 0) return;
// Close context menu when no items are selected
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
getExplorerStore().isContextMenuOpen = false;
}, [explorer.selectedItems, explorerStore.isContextMenuOpen]);
useShortcut('openObject', (e) => {
e.stopPropagation();
e.preventDefault();
if (quickPreviewStore.open || isRenaming) return;
doubleClick();
});
useEffect(() => {
if (explorer.isFetchingNextPage) {
const timer = setTimeout(() => setShowLoading(true), 100);
return () => clearTimeout(timer);
} else setShowLoading(false);
}, [explorer.isFetchingNextPage]);
useShortcut('showImageSlider', (e) => {
e.stopPropagation();
getExplorerLayoutStore().showImageSlider = !layoutStore.showImageSlider;
});
useEffect(() => {
if (explorer.layouts[layoutMode]) return;
// If the current layout mode is not available, switch to the first available layout mode
const layout = (Object.keys(explorer.layouts) as ExplorerLayout[]).find(
(key) => explorer.layouts[key]
);
explorer.settingsStore.layoutMode = layout ?? 'grid';
}, [layoutMode, explorer.layouts, explorer.settingsStore]);
useShortcut('toggleQuickPreview', (e) => {
if (isRenaming) return;
e.preventDefault();
getQuickPreviewStore().open = !quickPreviewStore.open;
});
useEffect(() => {
return () => {
const store = getExplorerStore();
store.isRenaming = false;
store.isContextMenuOpen = false;
store.isDragSelecting = false;
};
}, [layoutMode]);
useKeyCopyCutPaste();
// Handle wheel scroll while dragging items
useEffect(() => {
const element = explorer.scrollRef.current;
if (!element || explorerStore.drag?.type !== 'dragging') return;
if (!explorer.layouts[layoutMode]) return null;
const handleWheel = (e: WheelEvent) => {
element.scrollBy({ top: e.deltaY });
};
return (
<>
<div
ref={ref}
style={style}
className={clsx('h-full w-full', className)}
onMouseDown={(e) => {
if (e.button === 2 || (e.button === 0 && e.shiftKey)) return;
element.addEventListener('wheel', handleWheel);
return () => element.removeEventListener('wheel', handleWheel);
}, [explorer.scrollRef, explorerStore.drag?.type]);
explorer.resetSelectedItems();
}}
>
if (!explorer.layouts[layoutMode]) return null;
return (
<ViewContext.Provider value={{ ref, ...contextProps, selectable }}>
<div
ref={ref}
className="flex flex-1"
onMouseDown={(e) => {
if (e.button === 2 || (e.button === 0 && e.shiftKey)) return;
explorer.selectedItems.size !== 0 && explorer.resetSelectedItems();
}}
>
<div ref={setDroppableRef} className="h-full w-full">
{explorer.items === null || (explorer.items && explorer.items.length > 0) ? (
<ViewContext.Provider
value={{
...contextProps,
selectable:
explorer.selectable &&
!isContextMenuOpen &&
!isRenaming &&
!quickPreviewStore.open,
ref,
isRenaming,
isContextMenuOpen,
setIsRenaming,
setIsContextMenuOpen,
padding: viewPadding
}}
>
<>
{layoutMode === 'grid' && <GridView />}
{layoutMode === 'list' && <ListView />}
{layoutMode === 'media' && <MediaView />}
{showLoading && (
<Loader className="fixed bottom-10 left-0 w-[calc(100%+180px)]" />
)}
</ViewContext.Provider>
</>
) : (
emptyNotice
)}
</div>
</div>
{quickPreview.ref && createPortal(<QuickPreview />, quickPreview.ref)}
</>
);
}
);
{/* TODO: Move when adding columns view */}
<DragScrollable />
export const EmptyNotice = (props: {
icon?: Icon | ReactNode;
message?: ReactNode;
loading?: boolean;
}) => {
const { layoutMode } = useExplorerContext().useSettingsSnapshot();
const emptyNoticeIcon = (icon?: Icon) => {
const Icon =
icon ??
{
grid: GridFour,
media: MonitorPlay,
columns: Columns,
list: Rows
}[layoutMode];
return <Icon size={100} opacity={0.3} />;
};
if (props.loading) return null;
return (
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
{props.icon
? isValidElement(props.icon)
? props.icon
: emptyNoticeIcon(props.icon as Icon)
: emptyNoticeIcon()}
<p className="mt-5 text-sm font-medium">
{props.message !== undefined ? props.message : 'This list is empty'}
</p>
</div>
{quickPreview.ref && createPortal(<QuickPreview />, quickPreview.ref)}
</ViewContext.Provider>
);
};
const useKeyDownHandlers = ({ disabled }: { disabled: boolean }) => {
const useShortcuts = () => {
const explorer = useExplorerContext();
const explorerStore = useExplorerStore();
const quickPreviewStore = useQuickPreviewStore();
const os = useOperatingSystem();
const meta = useKeyMatcher('Meta');
const { doubleClick } = useViewItemDoubleClick();
const handleNewTag = useCallback(
async (event: KeyboardEvent) => {
const objects: Object[] = [];
useKeyCopyCutPaste();
for (const item of explorer.selectedItems) {
const object = getItemObject(item);
if (!object) return;
objects.push(object);
}
useShortcut('toggleQuickPreview', (e) => {
if (explorerStore.isRenaming) return;
e.preventDefault();
getQuickPreviewStore().open = !quickPreviewStore.open;
});
if (
!isNonEmpty(objects) ||
event.key.toUpperCase() !== 'N' ||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
)
return;
useShortcut('openObject', (e) => {
if (explorerStore.isRenaming || quickPreviewStore.open) return;
e.stopPropagation();
e.preventDefault();
doubleClick();
});
dialogManager.create((dp) => (
<CreateDialog {...dp} items={objects.map((item) => ({ type: 'Object', item }))} />
));
},
[os, explorer.selectedItems]
);
useShortcut('showImageSlider', (e) => {
if (explorerStore.isRenaming) return;
e.stopPropagation();
getExplorerLayoutStore().showImageSlider = !getExplorerLayoutStore().showImageSlider;
});
useEffect(() => {
const handlers = [handleNewTag];
const handler = (event: KeyboardEvent) => {
if (event.repeat || disabled) return;
for (const handler of handlers) handler(event);
};
document.body.addEventListener('keydown', handler);
return () => document.body.removeEventListener('keydown', handler);
}, [disabled, handleNewTag]);
useKeys([meta.key, 'KeyN'], () => {
if (explorerStore.isRenaming || quickPreviewStore.open) return;
const objects: Object[] = [];
for (const item of explorer.selectedItems) {
const object = getItemObject(item);
if (!object) return;
objects.push(object);
}
if (!isNonEmpty(objects)) return;
dialogManager.create((dp) => (
<CreateDialog {...dp} items={objects.map((item) => ({ type: 'Object', item }))} />
));
});
};

View file

@ -0,0 +1,63 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useExplorerContext } from '../Context';
import { getExplorerStore } from '../store';
/**
* Custom explorer dnd scroll handler as the default auto-scroll from dnd-kit is presenting issues
*/
export const useDragScrollable = ({ direction }: { direction: 'up' | 'down' }) => {
const explorer = useExplorerContext();
const [node, setNode] = useState<HTMLElement | null>(null);
const timeout = useRef<NodeJS.Timeout | null>(null);
const interval = useRef<NodeJS.Timer | null>(null);
useEffect(() => {
const element = node;
const scrollElement = explorer.scrollRef.current;
if (!element || !scrollElement) return;
const reset = () => {
if (timeout.current) {
clearTimeout(timeout.current);
timeout.current = null;
}
if (interval.current) {
clearInterval(interval.current);
interval.current = null;
}
};
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
if (getExplorerStore().drag?.type !== 'dragging') return reset();
const rect = element.getBoundingClientRect();
const isInside =
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom;
if (!isInside) return reset();
if (timeout.current) return;
timeout.current = setTimeout(() => {
interval.current = setInterval(() => {
scrollElement.scrollBy({ top: direction === 'up' ? -10 : 10 });
}, 5);
}, 1000);
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mouseover', handleMouseMove);
}, [direction, explorer.scrollRef, node]);
const ref = useCallback((nodeElement: HTMLElement | null) => setNode(nodeElement), []);
return { ref };
};

View file

@ -1,17 +0,0 @@
import { useCallback } from 'react';
import { ExplorerViewPadding } from '.';
export const useExplorerViewPadding = (padding?: number | ExplorerViewPadding) => {
const getPadding = useCallback(
(key: keyof ExplorerViewPadding) => (typeof padding === 'object' ? padding[key] : padding),
[padding]
);
return {
top: getPadding('top') ?? getPadding('y'),
bottom: getPadding('bottom') ?? getPadding('y'),
left: getPadding('left') ?? getPadding('x'),
right: getPadding('right') ?? getPadding('x')
};
};

View file

@ -7,16 +7,19 @@ import { useTopBarContext } from '../TopBar/Layout';
import { useExplorerContext } from './Context';
import ContextMenu from './ContextMenu';
import DismissibleNotice from './DismissibleNotice';
import { ExplorerPath, PATH_BAR_HEIGHT } from './ExplorerPath';
import { Inspector, INSPECTOR_WIDTH } from './Inspector';
import ExplorerContextMenu from './ParentContextMenu';
import { getQuickPreviewStore } from './QuickPreview/store';
import { getExplorerStore, useExplorerStore } from './store';
import { useKeyRevealFinder } from './useKeyRevealFinder';
import View, { EmptyNotice, ExplorerViewProps } from './View';
import { ExplorerPath, PATH_BAR_HEIGHT } from './View/ExplorerPath';
import { ExplorerViewProps, View } from './View';
import { EmptyNotice } from './View/EmptyNotice';
import 'react-slidedown/lib/slidedown.css';
import { useExplorerDnd } from './useExplorerDnd';
interface Props {
emptyNotice?: ExplorerViewProps['emptyNotice'];
contextMenu?: () => ReactNode;
@ -64,44 +67,43 @@ export default function Explorer(props: PropsWithChildren<Props>) {
useKeyRevealFinder();
useExplorerDnd();
const topBar = useTopBarContext();
return (
<>
<ExplorerContextMenu>
<div className="flex-1 overflow-hidden">
<div
ref={explorer.scrollRef}
className="custom-scroll explorer-scroll h-screen overflow-x-hidden"
style={
{
'--scrollbar-margin-top': `${topBar.topBarHeight}px`,
'--scrollbar-margin-bottom': `${
showPathBar ? PATH_BAR_HEIGHT + 2 : 0 // TODO: Fix for web app
}px`,
'paddingTop': topBar.topBarHeight,
'paddingRight': explorerStore.showInspector ? INSPECTOR_WIDTH : 0
} as CSSProperties
}
>
{explorer.items && explorer.items.length > 0 && <DismissibleNotice />}
<div
ref={explorer.scrollRef}
className="custom-scroll explorer-scroll flex flex-1 flex-col overflow-x-hidden"
style={
{
'--scrollbar-margin-top': `${topBar.topBarHeight}px`,
'--scrollbar-margin-bottom': `${showPathBar ? PATH_BAR_HEIGHT : 0}px`,
'paddingTop': topBar.topBarHeight,
'paddingRight': explorerStore.showInspector ? INSPECTOR_WIDTH : 0
} as CSSProperties
}
>
{explorer.items && explorer.items.length > 0 && <DismissibleNotice />}
<View
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
emptyNotice={
props.emptyNotice ?? (
<EmptyNotice
icon={FolderNotchOpen}
message="This folder is empty"
/>
)
}
listViewOptions={{ hideHeaderBorder: true }}
bottom={showPathBar ? PATH_BAR_HEIGHT : undefined}
/>
</div>
<View
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
emptyNotice={
props.emptyNotice ?? (
<EmptyNotice
icon={FolderNotchOpen}
message="This folder is empty"
/>
)
}
listViewOptions={{ hideHeaderBorder: true }}
bottom={showPathBar ? PATH_BAR_HEIGHT : undefined}
/>
</div>
</ExplorerContextMenu>
{showPathBar && <ExplorerPath />}
{explorerStore.showInspector && (

View file

@ -1,4 +1,3 @@
import type { ReadonlyDeep } from 'type-fest';
import { proxy, useSnapshot } from 'valtio';
import { proxySet } from 'valtio/utils';
import { z } from 'zod';
@ -107,7 +106,7 @@ export const createDefaultExplorerSettings = <TOrder extends Ordering>(args?: {
}
}) satisfies ExplorerSettings<TOrder>;
type CutCopyState =
export type CutCopyState =
| {
type: 'Idle';
}
@ -123,6 +122,18 @@ type CutCopyState =
};
};
type DragState =
| {
type: 'touched';
}
| {
type: 'dragging';
items: ExplorerItem[];
sourcePath?: string;
sourceLocationId?: number;
sourceTagId?: number;
};
const state = {
tagAssignMode: false,
showInspector: false,
@ -131,7 +142,10 @@ const state = {
mediaPlayerVolume: 0.7,
newThumbnails: proxySet() as Set<string>,
cutCopyState: { type: 'Idle' } as CutCopyState,
isDragging: false
drag: null as null | DragState,
isDragSelecting: false,
isRenaming: false,
isContextMenuOpen: false
};
export function flattenThumbnailKey(thumbKey: string[]) {
@ -160,7 +174,7 @@ export function getExplorerStore() {
return explorerStore;
}
export function isCut(item: ExplorerItem, cutCopyState: ReadonlyDeep<CutCopyState>) {
export function isCut(item: ExplorerItem, cutCopyState: CutCopyState) {
switch (item.type) {
case 'NonIndexedPath':
return (

View file

@ -0,0 +1,211 @@
import { useDndMonitor } from '@dnd-kit/core';
import { useState } from 'react';
import {
ExplorerItem,
getIndexedItemFilePath,
getItemFilePath,
libraryClient,
useLibraryMutation,
useZodForm
} from '@sd/client';
import { Dialog, RadixCheckbox, useDialog, UseDialogProps } from '@sd/ui';
import { Icon } from '~/components';
import { isNonEmptyObject } from '~/util';
import { useAssignItemsToTag } from '../settings/library/tags/CreateDialog';
import { useExplorerContext } from './Context';
import { getExplorerStore } from './store';
import { explorerDroppableSchema } from './useExplorerDroppable';
import { useExplorerSearchParams } from './util';
const getPaths = async (items: ExplorerItem[]) => {
const paths = items.map(async (item) => {
const filePath = getItemFilePath(item);
if (!filePath) return;
return 'path' in filePath
? filePath.path
: await libraryClient.query(['files.getPath', filePath.id]);
});
return (await Promise.all(paths)).filter((path): path is string => Boolean(path));
};
const getPathIdsPerLocation = (items: ExplorerItem[]) => {
return items.reduce(
(items, item) => {
const path = getIndexedItemFilePath(item);
if (!path || path.location_id === null) return items;
return {
...items,
[path.location_id]: [...(items[path.location_id] ?? []), path.id]
};
},
{} as Record<number, number[]>
);
};
export const useExplorerDnd = () => {
const explorer = useExplorerContext();
const [{ path }] = useExplorerSearchParams();
const cutFiles = useLibraryMutation('files.cutFiles');
const cutEphemeralFiles = useLibraryMutation('ephemeralFiles.cutFiles');
const assignItemsToTag = useAssignItemsToTag();
useDndMonitor({
onDragStart: () => {
if (explorer.selectedItems.size === 0) return;
getExplorerStore().drag = {
type: 'dragging',
items: [...explorer.selectedItems],
sourcePath: path ?? '/',
sourceLocationId:
explorer.parent?.type === 'Location' ? explorer.parent.location.id : undefined,
sourceTagId: explorer.parent?.type === 'Tag' ? explorer.parent.tag.id : undefined
};
},
onDragEnd: async ({ over }) => {
const { drag } = getExplorerStore();
getExplorerStore().drag = null;
if (!over || !drag || drag.type === 'touched') return;
const drop = explorerDroppableSchema.parse(over.data.current);
switch (drop.type) {
case 'location': {
if (!drop.data) {
cutEphemeralFiles.mutate({
sources: await getPaths(drag.items),
target_dir: drop.path
});
return;
}
const paths = getPathIdsPerLocation(drag.items);
if (isNonEmptyObject(paths)) {
const locationId = drop.data.id;
Object.entries(paths).map(([sourceLocationId, paths]) => {
cutFiles.mutate({
source_location_id: Number(sourceLocationId),
sources_file_path_ids: paths,
target_location_id: locationId,
target_location_relative_directory_path: drop.path
});
});
return;
}
cutEphemeralFiles.mutate({
sources: await getPaths(drag.items),
target_dir: drop.data.path + drop.path
});
break;
}
case 'explorer-item': {
switch (drop.data.type) {
case 'Path':
case 'Object': {
const { item } = drop.data;
const filePath = 'file_paths' in item ? item.file_paths[0] : item;
if (!filePath) return;
const paths = getPathIdsPerLocation(drag.items);
if (isNonEmptyObject(paths)) {
const locationId = filePath.location_id;
const path = filePath.materialized_path + filePath.name + '/';
Object.entries(paths).map(([sourceLocationId, paths]) => {
cutFiles.mutate({
source_location_id: Number(sourceLocationId),
sources_file_path_ids: paths,
target_location_id: locationId,
target_location_relative_directory_path: path
});
});
return;
}
const path = await libraryClient.query(['files.getPath', filePath.id]);
if (!path) return;
cutEphemeralFiles.mutate({
sources: await getPaths(drag.items),
target_dir: path
});
break;
}
case 'Location':
case 'NonIndexedPath': {
cutEphemeralFiles.mutate({
sources: await getPaths(drag.items),
target_dir: drop.data.item.path
});
}
}
break;
}
case 'tag': {
const items = drag.items.flatMap((item) => {
if (item.type !== 'Object' && item.type !== 'Path') return [];
return [item];
});
await assignItemsToTag(drop.data.id, items);
}
}
},
onDragCancel: () => (getExplorerStore().drag = null)
});
};
interface DndNoticeProps extends UseDialogProps {
count: number;
path: string;
onConfirm: (val: { dismissNotice: boolean }) => void;
}
const DndNotice = (props: DndNoticeProps) => {
const form = useZodForm();
const [dismissNotice, setDismissNotice] = useState(false);
return (
<Dialog
form={form}
onSubmit={form.handleSubmit(() => props.onConfirm({ dismissNotice: dismissNotice }))}
dialog={useDialog(props)}
title="Move Files"
icon={<Icon name="MoveLocation" size={28} />}
description={
<span className="break-all">
Are you sure you want to move {props.count} file{props.count > 1 ? 's' : ''} to{' '}
{props.path}?
</span>
}
ctaDanger
ctaLabel="Continue"
closeLabel="Cancel"
buttonsSideContent={
<RadixCheckbox
label="Don't show again"
name="ephemeral-alert-notice"
checked={dismissNotice}
onCheckedChange={(val) => typeof val === 'boolean' && setDismissNotice(val)}
/>
}
/>
);
};

View file

@ -0,0 +1,50 @@
import { useDraggable, UseDraggableArguments } from '@dnd-kit/core';
import { CSSProperties, HTMLAttributes } from 'react';
import { ExplorerItem } from '@sd/client';
import { getExplorerStore } from './store';
import { uniqueId } from './util';
export interface UseExplorerDraggableProps extends Omit<UseDraggableArguments, 'id'> {
data: ExplorerItem;
}
const draggableTypes: ExplorerItem['type'][] = ['Path', 'NonIndexedPath', 'Object'];
export const useExplorerDraggable = (props: UseExplorerDraggableProps) => {
const disabled = props.disabled || !draggableTypes.includes(props.data.type);
const { setNodeRef, ...draggable } = useDraggable({
...props,
id: uniqueId(props.data),
disabled: disabled
});
const onMouseDown = () => {
if (!disabled) getExplorerStore().drag = { type: 'touched' };
};
const onMouseLeave = () => {
const explorerStore = getExplorerStore();
if (explorerStore.drag?.type !== 'dragging') explorerStore.drag = null;
};
const onMouseUp = () => (getExplorerStore().drag = null);
const style = {
cursor: 'default',
outline: 'none'
} satisfies CSSProperties;
return {
...draggable,
setDraggableRef: setNodeRef,
listeners: {
...draggable.listeners,
onMouseDown,
onMouseLeave,
onMouseUp
} satisfies HTMLAttributes<Element>,
style
};
};

View file

@ -0,0 +1,211 @@
import { useDroppable, UseDroppableArguments } from '@dnd-kit/core';
import { useEffect, useId, useMemo, useState } from 'react';
import { NavigateOptions, To, useNavigate } from 'react-router';
import { createSearchParams } from 'react-router-dom';
import { z } from 'zod';
import { ExplorerItem, getItemFilePath, Location, Tag } from '@sd/client';
import { useExplorerContext } from './Context';
import { getExplorerStore } from './store';
type ExplorerItemType = ExplorerItem['type'];
const droppableTypes = [
'Location',
'NonIndexedPath',
'Object',
'Path',
'SpacedropPeer'
] satisfies ExplorerItemType[];
export interface UseExplorerDroppableProps extends Omit<UseDroppableArguments, 'id'> {
id?: string;
data?:
| {
type: 'location';
data?: Location | z.infer<typeof explorerLocationSchema>['data'];
path: string;
}
| { type: 'explorer-item'; data: ExplorerItem }
| { type: 'tag'; data: Tag };
allow?: ExplorerItemType | ExplorerItemType[];
navigateTo?: To | { to: To; options?: NavigateOptions } | number | (() => void);
}
const explorerPathSchema = z.object({
type: z.literal('Path'),
item: z.object({
id: z.number(),
name: z.string(),
location_id: z.number(),
materialized_path: z.string()
})
});
const explorerObjectSchema = z.object({
type: z.literal('Object'),
item: z.object({
file_paths: explorerPathSchema.shape.item.array()
})
});
const explorerNonIndexedPathSchema = z.object({
type: z.literal('NonIndexedPath'),
item: z.object({
name: z.string(),
path: z.string()
})
});
const explorerItemLocationSchema = z.object({
type: z.literal('Location'),
item: z.object({ id: z.number(), path: z.string() })
});
const explorerItemSchema = z.object({
type: z.literal('explorer-item'),
data: explorerPathSchema
.or(explorerNonIndexedPathSchema)
.or(explorerItemLocationSchema)
.or(explorerObjectSchema)
});
const explorerLocationSchema = z.object({
type: z.literal('location'),
data: z.object({ id: z.number(), path: z.string() }).optional(),
path: z.string()
});
const explorerTagSchema = z.object({
type: z.literal('tag'),
data: z.object({ id: z.number() })
});
export const explorerDroppableSchema = explorerItemSchema
.or(explorerLocationSchema)
.or(explorerTagSchema);
export const useExplorerDroppable = ({
allow,
navigateTo,
...props
}: UseExplorerDroppableProps) => {
const id = useId();
const navigate = useNavigate();
const explorer = useExplorerContext({ suspense: false });
const [canNavigate, setCanNavigate] = useState(true);
const { setNodeRef, ...droppable } = useDroppable({
...props,
id: props.id ?? id,
disabled: (!props.data && !navigateTo) || props.disabled
});
const isDroppable = useMemo(() => {
if (!droppable.isOver) return false;
const { drag } = getExplorerStore();
if (!drag || drag.type === 'touched') return false;
let allowedType: ExplorerItemType | ExplorerItemType[] | undefined = allow;
if (!allowedType) {
if (explorer?.parent) {
switch (explorer.parent.type) {
case 'Location':
case 'Ephemeral': {
allowedType = ['Path', 'NonIndexedPath', 'Object'];
break;
}
case 'Tag': {
allowedType = ['Path', 'Object'];
break;
}
}
} else if (props.data?.type === 'explorer-item') {
switch (props.data.data.type) {
case 'Path':
case 'NonIndexedPath': {
allowedType = ['Path', 'NonIndexedPath', 'Object'];
break;
}
case 'Object': {
allowedType = ['Path', 'Object'];
break;
}
}
} else allowedType = droppableTypes;
if (!allowedType) return false;
}
const schema = z.object({
type: Array.isArray(allowedType)
? z.union(
allowedType.map((type) => z.literal(type)) as unknown as [
z.ZodLiteral<ExplorerItemType>,
z.ZodLiteral<ExplorerItemType>,
...z.ZodLiteral<ExplorerItemType>[]
]
)
: z.literal(allowedType)
});
return schema.safeParse(drag.items[0]).success;
}, [allow, droppable.isOver, explorer?.parent, props.data]);
const filePath = props.data?.type === 'explorer-item' && getItemFilePath(props.data.data);
const isLocation = props.data?.type === 'explorer-item' && props.data.data.type === 'Location';
const isNavigable = isDroppable && canNavigate && (filePath || navigateTo || isLocation);
useEffect(() => {
if (!isNavigable) return;
const timeout = setTimeout(() => {
if (navigateTo) {
if (typeof navigateTo === 'function') {
navigateTo();
} else if (typeof navigateTo === 'object' && 'to' in navigateTo) {
navigate(navigateTo.to, navigateTo.options);
} else if (typeof navigateTo === 'number') {
navigate(navigateTo);
} else {
navigate(navigateTo);
}
} else if (filePath) {
if ('id' in filePath) {
navigate({
pathname: `../location/${filePath.location_id}`,
search: `${createSearchParams({
path: `${filePath.materialized_path}${filePath.name}/`
})}`
});
} else {
navigate({ search: `${createSearchParams({ path: filePath.path })}` });
}
} else if (
props.data?.type === 'explorer-item' &&
props.data.data.type === 'Location'
) {
navigate(`../location/${props.data.data.item.id}`);
}
// Timeout navigation
setCanNavigate(false);
setTimeout(() => setCanNavigate(true), 1250);
}, 1250);
return () => clearTimeout(timeout);
}, [navigate, props.data, navigateTo, filePath, isNavigable]);
const className = isNavigable
? 'animate-pulse transition-opacity duration-200 [animation-delay:1000ms]'
: undefined;
return { setDroppableRef: setNodeRef, ...droppable, isDroppable, className };
};

View file

@ -0,0 +1,24 @@
import * as Dnd from '@dnd-kit/core';
import { PropsWithChildren } from 'react';
export const DndContext = ({ children }: PropsWithChildren) => {
const sensors = Dnd.useSensors(
Dnd.useSensor(Dnd.PointerSensor, {
activationConstraint: {
distance: 4
}
})
);
return (
<Dnd.DndContext
sensors={sensors}
collisionDetection={Dnd.pointerWithin}
// We handle scrolling ourselves as dnd-kit
// auto-scroll is causing issues
autoScroll={{ enabled: false }}
>
{children}
</Dnd.DndContext>
);
};

View file

@ -0,0 +1,41 @@
import { Link } from 'react-router-dom';
import { useBridgeQuery, useFeatureFlag } from '@sd/client';
import { Button, Tooltip } from '@sd/ui';
import { Icon, SubtleButton } from '~/components';
import SidebarLink from '../Link';
import Section from '../Section';
export const Devices = () => {
const { data: node } = useBridgeQuery(['nodeState']);
const isPairingEnabled = useFeatureFlag('p2pPairing');
return (
<Section
name="Devices"
actionArea={
isPairingEnabled && (
<Link to="settings/library/nodes">
<SubtleButton />
</Link>
)
}
>
{node && (
<SidebarLink className="group relative w-full" to={`node/${node.id}`} key={node.id}>
<Icon name="Laptop" className="mr-1 h-5 w-5" />
<span className="truncate">{node.name}</span>
</SidebarLink>
)}
<Tooltip
label="Coming soon! This alpha release doesn't include library sync, it will be ready very soon."
position="right"
>
<Button disabled variant="dotted" className="mt-1 w-full">
Add Device
</Button>
</Tooltip>
</Section>
);
};

View file

@ -1,11 +1,13 @@
import { EjectSimple } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useMemo } from 'react';
import { PropsWithChildren, useMemo } from 'react';
import { useBridgeQuery, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Button, toast, tw } from '@sd/ui';
import { Icon, IconName } from '~/components';
import { useHomeDir } from '~/hooks/useHomeDir';
import { useExplorerDroppable } from '../../Explorer/useExplorerDroppable';
import { useExplorerSearchParams } from '../../Explorer/util';
import SidebarLink from './Link';
import Section from './Section';
import { SeeMore } from './SeeMore';
@ -78,36 +80,45 @@ export const EphemeralSection = () => {
<SidebarIcon name="Globe" />
<Name>Network</Name>
</SidebarLink>
{homeDir.data && (
<SidebarLink
to={`ephemeral/0?path=${homeDir.data}`}
className="group relative w-full border border-transparent"
<EphemeralLocation
navigateTo={`ephemeral/0?path=${homeDir.data}`}
path={homeDir.data ?? ''}
>
<SidebarIcon name="Home" />
<Name>Home</Name>
</SidebarLink>
</EphemeralLocation>
)}
{mountPoints.map((item) => {
if (!item) return;
const locationId = locationIdsForVolumes[item.mountPoint ?? ''];
const key = `${item.volumeIndex}-${item.index}`;
const name =
item.mountPoint === '/'
? 'Root'
: item.index === 0
? item.volume.name
: item.mountPoint;
const toPath =
locationId !== undefined
? `location/${locationId}`
: `ephemeral/${key}?path=${item.mountPoint}`;
return (
<SidebarLink
to={toPath}
<EphemeralLocation
key={key}
className="group relative w-full border border-transparent"
navigateTo={toPath}
path={
locationId !== undefined
? locationId.toString()
: item.mountPoint ?? ''
}
>
<SidebarIcon
name={
@ -120,10 +131,40 @@ export const EphemeralSection = () => {
/>
<Name>{name}</Name>
{item.volume.disk_type === 'Removable' && <EjectButton />}
</SidebarLink>
</EphemeralLocation>
);
})}
</SeeMore>
</Section>
);
};
const EphemeralLocation = ({
children,
path,
navigateTo
}: PropsWithChildren<{ path: string; navigateTo: string }>) => {
const [{ path: ephemeralPath }] = useExplorerSearchParams();
const { isDroppable, className, setDroppableRef } = useExplorerDroppable({
id: `sidebar-ephemeral-location-${path}`,
allow: ['Path', 'NonIndexedPath', 'Object'],
data: { type: 'location', path },
disabled: navigateTo.startsWith('location/') || ephemeralPath === path,
navigateTo: navigateTo
});
return (
<SidebarLink
ref={setDroppableRef}
to={navigateTo}
className={clsx(
'border',
isDroppable ? ' border-accent' : 'border-transparent',
className
)}
>
{children}
</SidebarLink>
);
};

View file

@ -1,219 +1,15 @@
import { X } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useMatch, useNavigate, useResolvedPath } from 'react-router';
import { Link, NavLink } from 'react-router-dom';
import {
arraysEqual,
useBridgeQuery,
useCache,
useFeatureFlag,
useLibraryMutation,
useLibraryQuery,
useNodes,
useOnlineLocations
} from '@sd/client';
import { Button, Tooltip } from '@sd/ui';
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
import { Folder, Icon, SubtleButton } from '~/components';
import SidebarLink from './Link';
import LocationsContextMenu from './LocationsContextMenu';
import Section from './Section';
import { SeeMore } from './SeeMore';
import TagsContextMenu from './TagsContextMenu';
export const LibrarySection = () => (
<>
<SavedSearches />
<Devices />
<Locations />
<Tags />
</>
);
function SavedSearches() {
const savedSearches = useLibraryQuery(['search.saved.list']);
const path = useResolvedPath('saved-search/:id');
const match = useMatch(path.pathname);
const currentSearchId = match?.params?.id;
const currentIndex = currentSearchId
? savedSearches.data?.findIndex((s) => s.id === Number(currentSearchId))
: undefined;
const navigate = useNavigate();
const deleteSavedSearch = useLibraryMutation(['search.saved.delete'], {
onSuccess() {
if (currentIndex !== undefined && savedSearches.data) {
const nextIndex = Math.min(currentIndex + 1, savedSearches.data.length - 2);
const search = savedSearches.data[nextIndex];
if (search) navigate(`saved-search/${search.id}`);
else navigate(`./`);
}
}
});
if (!savedSearches.data || savedSearches.data.length < 1) return null;
import { Devices } from './Devices';
import { Locations } from './Locations';
import { SavedSearches } from './SavedSearches';
import { Tags } from './Tags';
export const LibrarySection = () => {
return (
<Section
name="Saved Searches"
// actionArea={
// <Link to="settings/library/saved-searches">
// <SubtleButton />
// </Link>
// }
>
<SeeMore>
{savedSearches.data.map((search, i) => (
<SidebarLink
className="group/button relative w-full"
to={`saved-search/${search.id}`}
key={search.id}
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Folder size={18} />
</div>
<span className="truncate">{search.name}</span>
<Button
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded-full shadow group-hover/button:block"
size="icon"
variant="subtle"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
deleteSavedSearch.mutate(search.id);
}}
>
<X size={10} weight="bold" className="text-ink-dull/50" />
</Button>
</SidebarLink>
))}
</SeeMore>
</Section>
<>
<SavedSearches />
<Devices />
<Locations />
<Tags />
</>
);
}
function Devices() {
const node = useBridgeQuery(['nodeState']);
const isPairingEnabled = useFeatureFlag('p2pPairing');
return (
<Section
name="Devices"
actionArea={
isPairingEnabled && (
<Link to="settings/library/nodes">
<SubtleButton />
</Link>
)
}
>
{node.data && (
<SidebarLink
className="group relative w-full"
to={`node/${node.data.id}`}
key={node.data.id}
>
<Icon name="Laptop" size={20} className="mr-1" />
<span className="truncate">{node.data.name}</span>
</SidebarLink>
)}
<Tooltip
label="Coming soon! This alpha release doesn't include library sync, it will be ready very soon."
position="right"
>
<Button disabled variant="dotted" className="mt-1 w-full">
Add Device
</Button>
</Tooltip>
</Section>
);
}
function Locations() {
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const onlineLocations = useOnlineLocations();
return (
<Section
name="Locations"
actionArea={
<Link to="settings/library/locations">
<SubtleButton />
</Link>
}
>
<SeeMore>
{locations?.map((location) => (
<LocationsContextMenu key={location.id} locationId={location.id}>
<SidebarLink
className="borderradix-state-closed:border-transparent group relative w-full radix-state-open:border-accent"
to={`location/${location.id}`}
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Icon name="Folder" size={18} />
<div
className={clsx(
'absolute bottom-0.5 right-0 h-1.5 w-1.5 rounded-full',
onlineLocations.some((l) => arraysEqual(location.pub_id, l))
? 'bg-green-500'
: 'bg-red-500'
)}
/>
</div>
<span className="truncate">{location.name}</span>
</SidebarLink>
</LocationsContextMenu>
))}
</SeeMore>
<AddLocationButton className="mt-1" />
</Section>
);
}
function Tags() {
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
if (!tags?.length) return;
return (
<Section
name="Tags"
actionArea={
<NavLink to="settings/library/tags">
<SubtleButton />
</NavLink>
}
>
<SeeMore>
{tags?.map((tag) => (
<TagsContextMenu tagId={tag.id} key={tag.id}>
<SidebarLink
className="border radix-state-closed:border-transparent radix-state-open:border-accent"
to={`tag/${tag.id}`}
>
<div
className="h-[12px] w-[12px] shrink-0 rounded-full"
style={{ backgroundColor: tag.color || '#efefef' }}
/>
<span className="ml-1.5 truncate text-sm">{tag.name}</span>
</SidebarLink>
</TagsContextMenu>
))}
</SeeMore>
</Section>
);
}
};

View file

@ -1,4 +1,5 @@
import { Pencil, Plus, Trash } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react';
import { useNavigate } from 'react-router';
import { useLibraryContext } from '@sd/client';
import { ContextMenu as CM, dialogManager, toast } from '@sd/ui';
@ -7,12 +8,10 @@ import DeleteDialog from '~/app/$libraryId/settings/library/locations/DeleteDial
import { openDirectoryPickerDialog } from '~/app/$libraryId/settings/library/locations/openDirectoryPickerDialog';
import { usePlatform } from '~/util/Platform';
interface Props {
children: React.ReactNode;
locationId: number;
}
export default ({ children, locationId }: Props) => {
export const ContextMenu = ({
children,
locationId
}: PropsWithChildren<{ locationId: number }>) => {
const navigate = useNavigate();
const platform = usePlatform();
const libraryId = useLibraryContext().library.uuid;

View file

@ -0,0 +1,87 @@
import clsx from 'clsx';
import { Link, useMatch } from 'react-router-dom';
import {
arraysEqual,
Location as LocationType,
useCache,
useLibraryQuery,
useNodes,
useOnlineLocations
} from '@sd/client';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util';
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
import { Icon, SubtleButton } from '~/components';
import SidebarLink from '../Link';
import Section from '../Section';
import { SeeMore } from '../SeeMore';
import { ContextMenu } from './ContextMenu';
export const Locations = () => {
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const onlineLocations = useOnlineLocations();
return (
<Section
name="Locations"
actionArea={
<Link to="settings/library/locations">
<SubtleButton />
</Link>
}
>
<SeeMore>
{locations?.map((location) => (
<Location
key={location.id}
location={location}
online={onlineLocations.some((l) => arraysEqual(location.pub_id, l))}
/>
))}
</SeeMore>
<AddLocationButton className="mt-1" />
</Section>
);
};
const Location = ({ location, online }: { location: LocationType; online: boolean }) => {
const locationId = useMatch('/:libraryId/location/:locationId')?.params.locationId;
const [{ path }] = useExplorerSearchParams();
const { isDroppable, className, setDroppableRef } = useExplorerDroppable({
id: `sidebar-location-${location.id}`,
allow: ['Path', 'NonIndexedPath', 'Object'],
data: { type: 'location', path: '/', data: location },
disabled: Number(locationId) === location.id && !path,
navigateTo: `location/${location.id}`
});
return (
<ContextMenu locationId={location.id}>
<SidebarLink
ref={setDroppableRef}
to={`location/${location.id}`}
className={clsx(
'border radix-state-open:border-accent',
isDroppable ? ' border-accent' : 'border-transparent',
className
)}
>
<div className="relative mr-1 shrink-0 grow-0">
<Icon name="Folder" size={18} />
<div
className={clsx(
'absolute bottom-0.5 right-0 h-1.5 w-1.5 rounded-full',
online ? 'bg-green-500' : 'bg-red-500'
)}
/>
</div>
<span className="truncate">{location.name}</span>
</SidebarLink>
</ContextMenu>
);
};

View file

@ -0,0 +1,103 @@
import { X } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useMatch, useNavigate, useResolvedPath } from 'react-router';
import { useLibraryMutation, useLibraryQuery, type SavedSearch } from '@sd/client';
import { Button } from '@sd/ui';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
import { Folder } from '~/components';
import SidebarLink from '../Link';
import Section from '../Section';
import { SeeMore } from '../SeeMore';
export const SavedSearches = () => {
const savedSearches = useLibraryQuery(['search.saved.list']);
const path = useResolvedPath('saved-search/:id');
const match = useMatch(path.pathname);
const currentSearchId = match?.params?.id;
const currentIndex = currentSearchId
? savedSearches.data?.findIndex((s) => s.id === Number(currentSearchId))
: undefined;
const navigate = useNavigate();
const deleteSavedSearch = useLibraryMutation(['search.saved.delete'], {
onSuccess() {
if (currentIndex !== undefined && savedSearches.data) {
const nextIndex = Math.min(currentIndex + 1, savedSearches.data.length - 2);
const search = savedSearches.data[nextIndex];
if (search) navigate(`saved-search/${search.id}`);
else navigate(`./`);
}
}
});
if (!savedSearches.data || savedSearches.data.length < 1) return null;
return (
<Section
name="Saved Searches"
// actionArea={
// <Link to="settings/library/saved-searches">
// <SubtleButton />
// </Link>
// }
>
<SeeMore>
{savedSearches.data.map((search, i) => (
<SavedSearch
key={search.id}
search={search}
onDelete={() => deleteSavedSearch.mutate(search.id)}
/>
))}
</SeeMore>
</Section>
);
};
const SavedSearch = ({ search, onDelete }: { search: SavedSearch; onDelete(): void }) => {
const searchId = useMatch('/:libraryId/saved-search/:searchId')?.params.searchId;
const { isDroppable, className, setDroppableRef } = useExplorerDroppable({
id: `sidebar-saved-search-${search.id}`,
allow: ['Path', 'NonIndexedPath', 'Object'],
disabled: Number(searchId) === search.id,
navigateTo: `saved-search/${search.id}`
});
return (
<SidebarLink
ref={setDroppableRef}
to={`saved-search/${search.id}`}
className={clsx(
'group/button relative border border-transparent',
isDroppable && '!cursor-no-drop',
className
)}
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Folder size={18} />
</div>
<span className="truncate">{search.name}</span>
<Button
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded-full shadow group-hover/button:block"
size="icon"
variant="subtle"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
>
<X size={10} weight="bold" className="text-ink-dull/50" />
</Button>
</SidebarLink>
);
};

View file

@ -1,17 +1,14 @@
import { Pencil, Plus, Trash } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react';
import { useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { ContextMenu as CM, dialogManager } from '@sd/ui';
import CreateDialog from '~/app/$libraryId/settings/library/tags/CreateDialog';
import DeleteDialog from '~/app/$libraryId/settings/library/tags/DeleteDialog';
interface Props {
children: React.ReactNode;
tagId: number;
}
export default ({ children, tagId }: Props) => {
export const ContextMenu = ({ children, tagId }: PropsWithChildren<{ tagId: number }>) => {
const navigate = useNavigate();
return (
<CM.Root trigger={children}>
<CM.Item

View file

@ -0,0 +1,67 @@
import clsx from 'clsx';
import { NavLink, useMatch } from 'react-router-dom';
import { useCache, useLibraryQuery, useNodes, type Tag } from '@sd/client';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
import { SubtleButton } from '~/components';
import SidebarLink from '../Link';
import Section from '../Section';
import { SeeMore } from '../SeeMore';
import { ContextMenu } from './ContextMenu';
export const Tags = () => {
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
if (!tags?.length) return null;
return (
<Section
name="Tags"
actionArea={
<NavLink to="settings/library/tags">
<SubtleButton />
</NavLink>
}
>
<SeeMore>
{tags.map((tag) => (
<Tag key={tag.id} tag={tag} />
))}
</SeeMore>
</Section>
);
};
const Tag = ({ tag }: { tag: Tag }) => {
const tagId = useMatch('/:libraryId/tag/:tagId')?.params.tagId;
const { isDroppable, className, setDroppableRef } = useExplorerDroppable({
id: `sidebar-tag-${tag.id}`,
allow: ['Path', 'Object'],
data: { type: 'tag', data: tag },
navigateTo: `tag/${tag.id}`,
disabled: Number(tagId) === tag.id
});
return (
<ContextMenu key={tag.id} tagId={tag.id}>
<SidebarLink
ref={setDroppableRef}
to={`tag/${tag.id}`}
className={clsx(
'border radix-state-open:border-accent',
isDroppable ? ' border-accent' : 'border-transparent',
className
)}
>
<div
className="h-[12px] w-[12px] shrink-0 rounded-full"
style={{ backgroundColor: tag.color || '#efefef' }}
/>
<span className="ml-1.5 truncate text-sm">{tag.name}</span>
</SidebarLink>
</ContextMenu>
);
};

View file

@ -23,8 +23,10 @@ import {
} from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { DragOverlay } from '../Explorer/DragOverlay';
import { QuickPreviewContextProvider } from '../Explorer/QuickPreview/Context';
import { LayoutContext } from './Context';
import { DndContext } from './DndContext';
import Sidebar from './Sidebar';
const Layout = () => {
@ -69,27 +71,32 @@ const Layout = () => {
e.preventDefault();
}}
>
<Sidebar />
<div
className={clsx(
'relative flex w-full overflow-hidden',
showControls.transparentBg ? 'bg-app/80' : 'bg-app'
)}
>
{library ? (
<QuickPreviewContextProvider>
<LibraryContextProvider library={library}>
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
<Outlet />
</Suspense>
</LibraryContextProvider>
</QuickPreviewContextProvider>
) : (
<h1 className="p-4 text-white">
Please select or create a library in the sidebar.
</h1>
)}
</div>
<DndContext>
<Sidebar />
<div
className={clsx(
'relative flex w-full overflow-hidden',
showControls.transparentBg ? 'bg-app/80' : 'bg-app'
)}
>
{library ? (
<QuickPreviewContextProvider>
<LibraryContextProvider library={library}>
<Suspense
fallback={<div className="h-screen w-screen bg-app" />}
>
<Outlet />
<DragOverlay />
</Suspense>
</LibraryContextProvider>
</QuickPreviewContextProvider>
) : (
<h1 className="p-4 text-white">
Please select or create a library in the sidebar.
</h1>
)}
</div>
</DndContext>
</div>
</LayoutContext.Provider>
);

View file

@ -1,8 +1,9 @@
import { createContext, useContext, useState } from 'react';
import { createContext, useContext, useEffect, useState } from 'react';
import { Outlet } from 'react-router';
import { SearchFilterArgs } from '@sd/client';
import TopBar from '.';
import { getExplorerStore } from '../Explorer/store';
const TopBarContext = createContext<ReturnType<typeof useContextValue> | null>(null);
@ -33,6 +34,13 @@ function useContextValue() {
export const Component = () => {
const value = useContextValue();
// Reset drag state
useEffect(() => {
return () => {
getExplorerStore().drag = null;
};
}, []);
return (
<TopBarContext.Provider value={value}>
<TopBar />

View file

@ -1,10 +1,12 @@
import { ArrowLeft, ArrowRight } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { Tooltip } from '@sd/ui';
import { useKeyMatcher, useOperatingSystem, useShortcut } from '~/hooks';
import { useRoutingContext } from '~/RoutingContext';
import { useExplorerDroppable } from '../Explorer/useExplorerDroppable';
import TopBarButton from './TopBarButton';
export const NavigationButtons = () => {
@ -17,6 +19,16 @@ export const NavigationButtons = () => {
const canGoBack = currentIndex !== 0;
const canGoForward = currentIndex !== maxIndex;
const droppableBack = useExplorerDroppable({
navigateTo: -1,
disabled: !canGoBack
});
const droppableForward = useExplorerDroppable({
navigateTo: 1,
disabled: !canGoForward
});
useShortcut('navBackwardHistory', () => {
if (!canGoBack) return;
navigate(-1);
@ -49,9 +61,13 @@ export const NavigationButtons = () => {
<Tooltip keybinds={[icon, '[']} label="Navigate back">
<TopBarButton
rounding="left"
// className="text-[14px] text-ink-dull"
onClick={() => navigate(-1)}
disabled={!canGoBack}
ref={droppableBack.setDroppableRef}
className={clsx(
droppableBack.isDroppable && '!bg-app-selected',
droppableBack.className
)}
>
<ArrowLeft size={14} className="m-[4px]" weight="bold" />
</TopBarButton>
@ -59,9 +75,13 @@ export const NavigationButtons = () => {
<Tooltip keybinds={[icon, ']']} label="Navigate forward">
<TopBarButton
rounding="right"
// className="text-[14px] text-ink-dull"
onClick={() => navigate(1)}
disabled={!canGoForward}
ref={droppableForward.setDroppableRef}
className={clsx(
droppableForward.isDroppable && '!bg-app-selected',
droppableForward.className
)}
>
<ArrowRight size={14} className="m-[4px]" weight="bold" />
</TopBarButton>

View file

@ -15,7 +15,7 @@ import { NavigationButtons } from './NavigationButtons';
const TopBar = () => {
const transparentBg = useShowControls().transparentBg;
const { isDragging } = useExplorerStore();
const { isDragSelecting } = useExplorerStore();
const ref = useRef<HTMLDivElement>(null);
const tabs = useTabsContext();
@ -53,7 +53,7 @@ const TopBar = () => {
className={clsx(
'flex h-12 items-center gap-3.5 overflow-hidden px-3.5',
'duration-250 transition-[background-color,border-color] ease-out',
isDragging && 'pointer-events-none'
isDragSelecting && 'pointer-events-none'
)}
>
<div

View file

@ -34,7 +34,7 @@ import {
} from './Explorer/store';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
import { EmptyNotice } from './Explorer/View';
import { EmptyNotice } from './Explorer/View/EmptyNotice';
import { AddLocationButton } from './settings/library/locations/AddLocationButton';
import { useTopBarContext } from './TopBar/Layout';
import { TopBarPortal } from './TopBar/Portal';

View file

@ -34,7 +34,7 @@ import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Ex
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { useExplorerSearchParams } from '../Explorer/util';
import { EmptyNotice } from '../Explorer/View';
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
import SearchBar from '../Search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal';

View file

@ -18,7 +18,7 @@ import { usePathsExplorerQuery } from '../Explorer/queries';
import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { EmptyNotice } from '../Explorer/View';
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
import SearchOptions, { SearchContextProvider, useSearch, useSearchContext } from '../Search';
import SearchBar from '../Search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal';

View file

@ -27,7 +27,7 @@ export function useAssignItemsToTag() {
}
});
return (tagId: number, items: AssignTagItems, unassign: boolean) => {
return (tagId: number, items: AssignTagItems, unassign: boolean = false) => {
const targets = items.map<Target>((item) => {
if (item.type === 'Object') {
return { Object: item.item.id };

View file

@ -10,7 +10,7 @@ import { useObjectsExplorerQuery } from '../Explorer/queries/useObjectsExplorerQ
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
import { EmptyNotice } from '../Explorer/View';
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
import SearchBar from '../Search/SearchBar';
import { TopBarPortal } from '../TopBar/Portal';

View file

@ -4,26 +4,25 @@ export * from './useClickOutside';
export * from './useCounter';
export * from './useDebouncedForm';
export * from './useDismissibleNoticeStore';
export * from './useDragSelect';
export * from './useFocusState';
export * from './useInputState';
export * from './useIsDark';
export * from './useKeyDeleteFile';
export * from './useKeybindEventHandler';
export * from './useKeybind';
export * from './useKeybindEventHandler';
export * from './useOperatingSystem';
export * from './useScrolled';
// export * from './useSearchStore';
export * from './useIsLocationIndexing';
export * from './useIsTextTruncated';
export * from './useKeyCopyCutPaste';
export * from './useKeyMatcher';
export * from './useRedirectToNewLocation';
export * from './useRouteTitle';
export * from './useShortcut';
export * from './useShowControls';
export * from './useSpacedropState';
export * from './useTheme';
export * from './useWindowState';
export * from './useZodRouteParams';
export * from './useZodSearchParams';
export * from './useIsTextTruncated';
export * from './useKeyMatcher';
export * from './useKeyCopyCutPaste';
export * from './useRedirectToNewLocation';
export * from './useWindowState';
export * from './useIsLocationIndexing';
export * from './useRouteTitle';

View file

@ -5,7 +5,8 @@ export const dismissibleNoticeStore = valtioPersist('dismissible-notice', {
mediaView: false,
gridView: false,
listView: false,
ephemeral: false
ephemeral: false,
ephemeralMoveFiles: false
});
export function useDismissibleNoticeStore() {

View file

@ -1,39 +0,0 @@
import DragSelect from 'dragselect';
import React, { createContext, useContext, useEffect, useState } from 'react';
type ProviderProps = {
children: React.ReactNode;
settings?: ConstructorParameters<typeof DragSelect>[0];
};
const Context = createContext<DragSelect | undefined>(undefined);
function DragSelectProvider({ children, settings = {} }: ProviderProps) {
const [ds, setDS] = useState<DragSelect>();
useEffect(() => {
setDS((prevState) => {
if (prevState) return prevState;
return new DragSelect({});
});
return () => {
if (ds) {
console.log('stop');
ds.stop();
setDS(undefined);
}
};
}, [ds]);
useEffect(() => {
ds?.setSettings(settings);
}, [ds, settings]);
return <Context.Provider value={ds}>{children}</Context.Provider>;
}
function useDragSelect() {
return useContext(Context);
}
export { DragSelectProvider, useDragSelect };

View file

@ -9,6 +9,8 @@
"typecheck": "tsc -b"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/inter": "^4.5.15",
"@headlessui/react": "^1.7.17",
"@icons-pack/react-simple-icons": "^9.1.0",
@ -35,7 +37,6 @@
"clsx": "^2.0.0",
"crypto-random-string": "^5.0.0",
"dayjs": "^1.11.10",
"dragselect": "^2.7.4",
"framer-motion": "^10.16.4",
"immer": "^10.0.3",
"prismjs": "^1.29.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -121,6 +121,8 @@ import Mesh20 from './Mesh-20.png';
import Mesh from './Mesh.png';
import Mobile_Light from './Mobile_Light.png';
import Mobile from './Mobile.png';
import MoveLocation_Light from './MoveLocation_Light.png';
import MoveLocation from './MoveLocation.png';
import Movie_Light from './Movie_Light.png';
import Movie from './Movie.png';
import NewLocation from './NewLocation.png';
@ -290,6 +292,8 @@ export {
Mesh_Light,
Mobile,
Mobile_Light,
MoveLocation,
MoveLocation_Light,
Movie,
Movie_Light,
NewLocation,

View file

@ -2,7 +2,6 @@
import * as RDialog from '@radix-ui/react-dialog';
import { animated, useTransition } from '@react-spring/web';
import { iconNames } from '@sd/assets/util';
import clsx from 'clsx';
import { ReactElement, ReactNode, useEffect } from 'react';
import { FieldValues, UseFormHandleSubmit } from 'react-hook-form';
@ -123,7 +122,7 @@ export interface DialogProps<S extends FieldValues>
ctaDanger?: boolean;
closeLabel?: string;
cancelBtn?: boolean;
description?: string;
description?: ReactNode;
onCancelled?: boolean | (() => void);
submitDisabled?: boolean;
transformOrigin?: string;
@ -250,7 +249,7 @@ export function Dialog<S extends FieldValues>({
</div>
<div
className={clsx(
'flex justify-end space-x-2 border-t border-app-line bg-app-input/60 p-3'
'flex items-center justify-end space-x-2 border-t border-app-line bg-app-input/60 p-3'
)}
>
{form.formState.isSubmitting && <Loader />}

View file

@ -51,7 +51,7 @@ importers:
version: 5.2.2
vite:
specifier: ^4.5.0
version: 4.5.0(@types/node@18.17.19)
version: 4.5.0(less@4.2.0)
.github/actions/publish-artifacts:
dependencies:
@ -626,7 +626,7 @@ importers:
version: 5.2.2
vite:
specifier: ^4.5.0
version: 4.5.0(@types/node@18.17.19)
version: 4.5.0(less@4.2.0)
vite-plugin-html:
specifier: ^3.2.0
version: 3.2.0(vite@4.5.0)
@ -639,6 +639,12 @@ importers:
interface:
dependencies:
'@dnd-kit/core':
specifier: ^6.1.0
version: 6.1.0(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@18.2.0)
'@fontsource/inter':
specifier: ^4.5.15
version: 4.5.15
@ -717,9 +723,6 @@ importers:
dayjs:
specifier: ^1.11.10
version: 1.11.10
dragselect:
specifier: ^2.7.4
version: 2.7.4
framer-motion:
specifier: ^10.16.4
version: 10.16.4(react-dom@18.2.0)(react@18.2.0)
@ -4852,6 +4855,37 @@ packages:
engines: {node: '>=10.0.0'}
dev: true
/@dnd-kit/accessibility@3.1.0(react@18.2.0):
resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
peerDependencies:
react: '>=16.8.0'
dependencies:
react: 18.2.0
tslib: 2.6.2
dev: false
/@dnd-kit/core@6.1.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@dnd-kit/accessibility': 3.1.0(react@18.2.0)
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tslib: 2.6.2
dev: false
/@dnd-kit/utilities@3.2.2(react@18.2.0):
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
dependencies:
react: 18.2.0
tslib: 2.6.2
dev: false
/@docsearch/css@3.5.2:
resolution: {integrity: sha512-SPiDHaWKQZpwR2siD0KQUwlStvIAnEyK6tAE2h2Wuoq8ue9skzhlyVQ1ddzOxX6khULnAALDiR/isSF3bnuciA==}
dev: false
@ -10854,7 +10888,7 @@ packages:
'@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2)
'@types/babel__core': 7.20.3
react-refresh: 0.14.0
vite: 4.5.0(sass@1.69.5)
vite: 4.5.0(@types/node@18.17.19)
transitivePeerDependencies:
- supports-color
dev: true
@ -13126,10 +13160,6 @@ packages:
resolution: {integrity: sha512-+3NaRjWktb5r61ZFoDejlykPEFKT5N/LkbXsaddlw6xNSXBanUYpFc2AXXpbJDilPHazcSreU/DpQIaxfX0NfQ==}
dev: false
/dragselect@2.7.4:
resolution: {integrity: sha512-j0qFl4xvsyImlSTn9erDCCT4SSPUMssgKAYuKqhsPr8WPphLghHfjDd4WR2jLjL91fTQQlbbIJ/7T2qwD2hghQ==}
dev: false
/duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
dev: true
@ -23307,7 +23337,7 @@ packages:
'@rollup/pluginutils': 5.0.5
'@svgr/core': 8.1.0(typescript@5.2.2)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0)
vite: 4.5.0(sass@1.69.5)
vite: 4.5.0(@types/node@18.17.19)
transitivePeerDependencies:
- rollup
- supports-color