diff --git a/interface/app/$libraryId/Explorer/Context.tsx b/interface/app/$libraryId/Explorer/Context.tsx index ab9b87c3f..7ca71f576 100644 --- a/interface/app/$libraryId/Explorer/Context.tsx +++ b/interface/app/$libraryId/Explorer/Context.tsx @@ -26,4 +26,4 @@ export const ExplorerContextProvider = >({ children }: PropsWithChildren<{ explorer: TExplorer; -}>) => {children}; +}>) => {children}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx index cf15ddddc..03701a59c 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx @@ -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({ explorerView.setIsRenaming(true)} + onClick={() => (getExplorerStore().isRenaming = true)} /> ); } diff --git a/interface/app/$libraryId/Explorer/DragOverlay.tsx b/interface/app/$libraryId/Explorer/DragOverlay.tsx new file mode 100644 index 000000000..d38be45b1 --- /dev/null +++ b/interface/app/$libraryId/Explorer/DragOverlay.tsx @@ -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(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 ( + + {!drag || drag.type === 'touched' ? null : ( +
+ {drag.items.length > 1 && ( +
+ {drag.items.length} +
+ )} + + {(drag.items.slice(0, 8) as ExplorerItem[]).map((item, i, items) => ( +
7 && [ + i + 1 === items.length && 'opacity-10', + i + 2 === items.length && 'opacity-50', + i + 3 === items.length && 'opacity-90' + ] + )} + > + + +
+ ))} +
+ )} +
+ ); +}); diff --git a/interface/app/$libraryId/Explorer/ExplorerDraggable.tsx b/interface/app/$libraryId/Explorer/ExplorerDraggable.tsx new file mode 100644 index 000000000..f6172b0f9 --- /dev/null +++ b/interface/app/$libraryId/Explorer/ExplorerDraggable.tsx @@ -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, 'draggable'> & { + draggable: UseExplorerDraggableProps; +}) => { + const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable(draggable); + + return ( +
+ {props.children} +
+ ); +}; diff --git a/interface/app/$libraryId/Explorer/ExplorerDroppable.tsx b/interface/app/$libraryId/Explorer/ExplorerDroppable.tsx new file mode 100644 index 000000000..f08459c02 --- /dev/null +++ b/interface/app/$libraryId/Explorer/ExplorerDroppable.tsx @@ -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 & { droppable: UseExplorerDroppableProps }) => { + const { isDroppable, className, setDroppableRef } = useExplorerDroppable(droppable); + + const context = useMemo(() => ({ isDroppable }), [isDroppable]); + + return ( + +
+ {children} +
+
+ ); +}; diff --git a/interface/app/$libraryId/Explorer/ExplorerPath.tsx b/interface/app/$libraryId/Explorer/ExplorerPath.tsx new file mode 100644 index 000000000..33e020431 --- /dev/null +++ b/interface/app/$libraryId/Explorer/ExplorerPath.tsx @@ -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 ( +
+ {paths.map((path) => ( + handleOnClick(path)} + disabled={path.pathname === (searchPath ?? (location && '/'))} + /> + ))} + + {selectedItem && (!queryPath || filePathname) && ( +
+ + + {getExplorerItemData(selectedItem).fullName} + +
+ )} +
+ ); +}); + +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 ( + + ); +}; diff --git a/interface/app/$libraryId/Explorer/FilePath/LayeredFileIcon.tsx b/interface/app/$libraryId/Explorer/FilePath/LayeredFileIcon.tsx index c0217b89b..4656dc4b9 100644 --- a/interface/app/$libraryId/Explorer/FilePath/LayeredFileIcon.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/LayeredFileIcon.tsx @@ -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 { @@ -16,28 +16,32 @@ const positionConfig: Record = { Config: 'flex h-full w-full items-center justify-center' }; -const LayeredFileIcon = ({ kind, extension, ...props }: LayeredFileIconProps) => { - const iconImg = ; +const LayeredFileIcon = forwardRef( + ({ kind, extension, ...props }, ref) => { + const iconImg = ; - 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 - ) : ( -
- {iconImg} -
- + return IconComponent == null ? ( + iconImg + ) : ( +
+ {iconImg} +
+ +
-
- ); -}; + ); + } +); export default LayeredFileIcon; diff --git a/interface/app/$libraryId/Explorer/FilePath/Original.tsx b/interface/app/$libraryId/Explorer/FilePath/Original.tsx index 2b09f0a96..f1f7a83d0 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Original.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Original.tsx @@ -182,7 +182,7 @@ interface VideoProps extends VideoHTMLAttributes { blackBarsSize?: number; } -const Video = memo(({ paused, blackBars, blackBarsSize, className, ...props }: VideoProps) => { +const Video = ({ paused, blackBars, blackBarsSize, className, ...props }: VideoProps) => { const ref = useRef(null); const size = useSize(ref); @@ -220,4 +220,4 @@ const Video = memo(({ paused, blackBars, blackBarsSize, className, ...props }: V

Video preview is not supported.

); -}); +}; diff --git a/interface/app/$libraryId/Explorer/FilePath/RenameTextBox.tsx b/interface/app/$libraryId/Explorer/FilePath/RenameTextBox.tsx index 00b1e3cf6..eabf38cc9 100644 --- a/interface/app/$libraryId/Explorer/FilePath/RenameTextBox.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/RenameTextBox.tsx @@ -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 { +export interface RenameTextBoxProps extends React.HTMLAttributes { name: string; onRename: (newName: string) => void; disabled?: boolean; lines?: number; + // Temporary solution for TruncatedText in list view + idleClassName?: string; } -export const RenameTextBox = forwardRef( - ({ name, onRename, disabled, className, lines, ...props }, _ref) => { - const explorerView = useExplorerViewContext(); +export const RenameTextBox = forwardRef( + ({ 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(null); + useImperativeHandle(_ref, () => ref.current); const renamable = useRef(false); const timeout = useRef(null); - const ref = useRef(null); - useImperativeHandle(_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( } }; - 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( 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(
( 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( onBlur={() => { handleRename(); resetState(); - explorerView.setIsRenaming(false); + getExplorerStore().isRenaming = false; }} onKeyDown={handleKeyDown} {...props} @@ -184,16 +193,30 @@ export const RenameTextBox = forwardRef( {allowRename ? ( name ) : ( - -
{name}
-
+ )}
); } ); + +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 ( + +
{text}
+
+ ); +}); diff --git a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx index 6ac4e2d3d..02170cbe5 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx @@ -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; } type ThumbType = { variant: 'original' } | { variant: 'thumbnail' } | { variant: 'icon' }; -export const FileThumb = memo((props: ThumbProps) => { +export const FileThumb = forwardRef((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 ( onLoad('thumbnail')} @@ -169,6 +175,8 @@ export const FileThumb = memo((props: ThumbProps) => { case 'icon': return ( { /> ); default: - return null; + return <>; } })(); @@ -233,17 +241,16 @@ interface ThumbnailProps extends Omit { extension?: string; } -const Thumbnail = memo( - ({ - crossOrigin, - blackBars, - blackBarsSize, - extension, - cover, - className, - ...props - }: ThumbnailProps) => { +const Thumbnail = forwardRef( + ( + { crossOrigin, blackBars, blackBarsSize, extension, cover, className, style, ...props }, + _ref + ) => { const ref = useRef(null); + useImperativeHandle( + _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' diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index ad85da22f..16fd91318 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -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) => { diff --git a/interface/app/$libraryId/Explorer/ViewContext.ts b/interface/app/$libraryId/Explorer/View/Context.ts similarity index 65% rename from interface/app/$libraryId/Explorer/ViewContext.ts rename to interface/app/$libraryId/Explorer/View/Context.ts index 5d2bfb155..b339c98d2 100644 --- a/interface/app/$libraryId/Explorer/ViewContext.ts +++ b/interface/app/$libraryId/Explorer/View/Context.ts @@ -1,18 +1,10 @@ import { createContext, useContext, type ReactNode, type RefObject } from 'react'; -import { ExplorerViewPadding } from './View'; - export interface ExplorerViewContext { ref: RefObject; top?: number; bottom?: number; contextMenu?: ReactNode; - isContextMenuOpen?: boolean; - setIsContextMenuOpen?: (isOpen: boolean) => void; - isRenaming: boolean; - setIsRenaming: (isRenaming: boolean) => void; - padding?: Omit; - gap?: number | { x?: number; y?: number }; selectable: boolean; listViewOptions?: { hideHeaderBorder?: boolean; diff --git a/interface/app/$libraryId/Explorer/View/DragScrollable.tsx b/interface/app/$libraryId/Explorer/View/DragScrollable.tsx new file mode 100644 index 000000000..119e06c8f --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/DragScrollable.tsx @@ -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' && ( + + )} + + + ); +}; diff --git a/interface/app/$libraryId/Explorer/View/EmptyNotice.tsx b/interface/app/$libraryId/Explorer/View/EmptyNotice.tsx new file mode 100644 index 000000000..a0beb65a1 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/EmptyNotice.tsx @@ -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 ; + }; + + if (props.loading) return null; + + return ( +
+ {props.icon + ? isValidElement(props.icon) + ? props.icon + : emptyNoticeIcon(props.icon as Icon) + : emptyNoticeIcon()} + +

+ {props.message !== undefined ? props.message : 'This list is empty'} +

+
+ ); +}; diff --git a/interface/app/$libraryId/Explorer/View/ExplorerPath.tsx b/interface/app/$libraryId/Explorer/View/ExplorerPath.tsx deleted file mode 100644 index ff9f69e40..000000000 --- a/interface/app/$libraryId/Explorer/View/ExplorerPath.tsx +++ /dev/null @@ -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); - - 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 ( -
- {data?.map((p, index) => { - return ( - p.name)} - path={p} - index={index} - fullPathOnClick={fullPathOnClick} - onClick={() => pathRedirectHandler(p.name, index)} - /> - ); - })} - {selectedItem && ( -
- {data && data.length > 0 && } - <> - - {'name' in selectedItem.item ? ( - - {selectedItem.item.name} - - ) : ( - - {selectedItem.item.file_paths[0]?.name} - - )} - -
- )} -
- ); -}); - -interface Props extends ComponentProps<'div'> { - paths: string[]; - path: { - name: string; - }; - fullPathOnClick: boolean; - index: number; -} - -const Path = ({ paths, path, fullPathOnClick, index, ...rest }: Props) => { - return ( -
- - - {path.name} - - {index !== (paths?.length as number) - 1 && ( - - )} -
- ); -}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/Item.tsx b/interface/app/$libraryId/Explorer/View/Grid/Item.tsx new file mode 100644 index 000000000..fc1fda8e3 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/Item.tsx @@ -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, '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 ( +
{ + if (explorerView.selectable && !explorer.selectedItems.has(item)) { + explorer.resetSelectedItems([item]); + grid.selecto?.current?.setSelectedTargets([e.currentTarget]); + } + }} + > + {children({ item: item, selected, cut })} +
+ ); +}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/context.tsx b/interface/app/$libraryId/Explorer/View/Grid/context.tsx new file mode 100644 index 000000000..64494527b --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/Grid/context.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; +import Selecto from 'react-selecto'; + +interface GridContext { + selecto?: React.RefObject; + selectoUnselected: React.MutableRefObject>; + getElementById: (id: string) => Element | null | undefined; +} + +export const GridContext = createContext(null); + +export const useGridContext = () => { + const ctx = useContext(GridContext); + + if (ctx === null) throw new Error('GridContext.Provider not found!'); + + return ctx; +}; diff --git a/interface/app/$libraryId/Explorer/View/GridList.tsx b/interface/app/$libraryId/Explorer/View/Grid/index.tsx similarity index 77% rename from interface/app/$libraryId/Explorer/View/GridList.tsx rename to interface/app/$libraryId/Explorer/View/Grid/index.tsx index c9bd47608..c95cfc678 100644 --- a/interface/app/$libraryId/Explorer/View/GridList.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/index.tsx @@ -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; - selectoUnSelected: React.MutableRefObject>; -} | 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) => 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 ( -
{ - 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 })} -
- ); -}; + 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(null); - const selectoUnSelected = useRef>(new Set()); + const selectoUnselected = useRef>(new Set()); const selectoFirstColumn = useRef(); const selectoLastColumn = useRef(); + // 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(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(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 ( - + {explorer.allowMultiSelect && ( { 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 ( - { if (e.button !== 0 || !explorerView.selectable) return; @@ -698,10 +620,10 @@ export default ({ children }: { children: RenderItem }) => { }} > {children} - + ); }} - + ); -}; +}); diff --git a/interface/app/$libraryId/Explorer/View/GridView.tsx b/interface/app/$libraryId/Explorer/View/GridView.tsx deleted file mode 100644 index 04805b14f..000000000 --- a/interface/app/$libraryId/Explorer/View/GridView.tsx +++ /dev/null @@ -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 ( - - ); -}); - -export default () => { - const explorerView = useExplorerViewContext(); - - return ( - - {({ item, selected, cut }) => ( - - )} - - ); -}; diff --git a/interface/app/$libraryId/Explorer/View/GridView/Item/Context.tsx b/interface/app/$libraryId/Explorer/View/GridView/Item/Context.tsx new file mode 100644 index 000000000..cb7f9e592 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/GridView/Item/Context.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +import { GridViewItemProps } from '.'; + +export const GridViewItemContext = createContext(null); + +export const useGridViewItemContext = () => { + const ctx = useContext(GridViewItemContext); + + if (ctx === null) throw new Error('GridViewItemContext.Provider not found!'); + + return ctx; +}; diff --git a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx new file mode 100644 index 000000000..9277bbda2 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx @@ -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 ( + + + + + + + + ); +}); + +const InnerDroppable = () => { + const item = useGridViewItemContext(); + const { isDroppable } = useExplorerDroppableContext(); + + return ( + <> +
+ +
+ + + + + + + ); +}; + +const ItemFileThumb = () => { + const item = useGridViewItemContext(); + + const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable({ + data: item.data + }); + + return ( + + ); +}; + +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 ( +
+ {`${bytes}`} +
+ ); +}; diff --git a/interface/app/$libraryId/Explorer/View/GridView/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/index.tsx new file mode 100644 index 000000000..8d4c24bf0 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/GridView/index.tsx @@ -0,0 +1,12 @@ +import Grid from '../Grid'; +import { GridViewItem } from './Item'; + +export const GridView = () => { + return ( + + {({ item, selected, cut }) => ( + + )} + + ); +}; diff --git a/interface/app/$libraryId/Explorer/View/ListView/Item.tsx b/interface/app/$libraryId/Explorer/View/ListView/Item.tsx new file mode 100644 index 000000000..89badcd9a --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/ListView/Item.tsx @@ -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[]; +} + +export const ListViewItem = memo(({ data, selected, cells }: Props) => { + const filePath = getItemFilePath(data); + + return ( + + + + + {cells.map((cell) => ( + + ))} + + + + ); +}); + +const DroppableOverlay = () => { + const { isDroppable } = useExplorerDroppableContext(); + if (!isDroppable) return null; + + return
; +}; + +const Cell = ({ cell, selected }: { cell: Cell; selected: boolean }) => { + useTableContext(); // Force re-render for column sizing + + return ; +}; + +const InnerCell = memo( + (props: { cell: Cell; size: number; selected: boolean }) => { + const value = useMemo(() => props.cell.getValue(), [props.cell]); + + return ( +
+ {value + ? `${value}` + : flexRender(props.cell.column.columnDef.cell, { + ...props.cell.getContext(), + selected: props.selected + })} +
+ ); + } +); diff --git a/interface/app/$libraryId/Explorer/View/ListView/TableRow.tsx b/interface/app/$libraryId/Explorer/View/ListView/TableRow.tsx new file mode 100644 index 000000000..53c9f1d08 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/ListView/TableRow.tsx @@ -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; + previousRow?: Row; + nextRow?: Row; +} + +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 ( + <> +
+ {isPreviousRowSelected && ( +
+ )} +
+ + + + ); +}; diff --git a/interface/app/$libraryId/Explorer/View/ListView/context.tsx b/interface/app/$libraryId/Explorer/View/ListView/context.tsx new file mode 100644 index 000000000..c3b9f32fe --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/ListView/context.tsx @@ -0,0 +1,16 @@ +import { ColumnSizingState } from '@tanstack/react-table'; +import { createContext, useContext } from 'react'; + +interface TableContext { + columnSizing: ColumnSizingState; +} + +export const TableContext = createContext(null); + +export const useTableContext = () => { + const ctx = useContext(TableContext); + + if (ctx === null) throw new Error('TableContext.Provider not found!'); + + return ctx; +}; diff --git a/interface/app/$libraryId/Explorer/View/ListView/index.tsx b/interface/app/$libraryId/Explorer/View/ListView/index.tsx index 8b80f135d..55e38d630 100644 --- a/interface/app/$libraryId/Explorer/View/ListView/index.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView/index.tsx @@ -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; - 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 ( - - {props.row.getVisibleCells().map((cell) => ( - - ))} - - ); -}); - -const HeaderColumnName = ({ name }: { name: string }) => { - const textRef = useRef(null); - - const isTruncated = useIsTextTruncated(textRef); - - return ( -
- {isTruncated ? ( - - {name} - - ) : ( - {name} - )} -
- ); -}; +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(null); - const tableHeaderRef = useRef(null); + const tableHeaderRef = useRef(null); const tableBodyRef = useRef(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) { + const handleRowContextMenu = (row: Row) => { 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) => { @@ -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[]) - .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[]) + .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 ( -
{ - if (e.button !== 0) return; - - e.stopPropagation(); - setIsLeftMouseDown(true); - }} - className={clsx(!initialized && 'invisible')} - > - {sized && ( - <> - - - {table.getHeaderGroups().map((headerGroup) => ( -
- {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 ( -
{ - 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' ? ( - - ) : ( - cellContent - )} - - {orderingDirection === 'Asc' && ( - - )} - - {orderingDirection === 'Desc' && ( - - )} - -
{ - 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" - /> - - )} -
- ); - })} -
- ))} -
- } + +
{ + if (e.button !== 0) return; + e.stopPropagation(); + setIsLeftMouseDown(true); + }} + className={clsx(!initialized && 'invisible')} + > + {sized && ( + <> + - {table.getAllLeafColumns().map((column) => { - if (column.id === 'name') return null; - return ( - - ); - })} - - - -
-
- {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 ( + { + 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' + )} > -
- {selectedPrior && ( -
- )} -
+ {table.getHeaderGroups().map((headerGroup) => ( +
+ {headerGroup.headers.map((header, i) => { + const size = header.column.getSize(); - + 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 ( +
{ + 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 : ( + <> + + {cellContent} + + + {orderingDirection === + 'Asc' && ( + + )} + + {orderingDirection === + 'Desc' && ( + + )} + +
{ + 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" + /> + + )} +
+ ); + })} +
+ ))}
- ); - })} + } + > + {table.getAllLeafColumns().map((column) => { + if (column.id === 'name') return null; + return ( + + ); + })} + + + +
+
+ {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 ( +
handleRowClick(e, row)} + onContextMenu={() => handleRowContextMenu(row)} + > + +
+ ); + })} +
-
- - )} -
+ + )} +
+ ); -}; +}); diff --git a/interface/app/$libraryId/Explorer/View/ListView/util/ranges.tsx b/interface/app/$libraryId/Explorer/View/ListView/useRanges.tsx similarity index 100% rename from interface/app/$libraryId/Explorer/View/ListView/util/ranges.tsx rename to interface/app/$libraryId/Explorer/View/ListView/useRanges.tsx diff --git a/interface/app/$libraryId/Explorer/View/ListView/util/table.tsx b/interface/app/$libraryId/Explorer/View/ListView/useTable.tsx similarity index 77% rename from interface/app/$libraryId/Explorer/View/ListView/util/table.tsx rename to interface/app/$libraryId/Explorer/View/ListView/useTable.tsx index dd955a493..0c7fb981e 100644 --- a/interface/app/$libraryId/Explorer/View/ListView/util/table.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView/useTable.tsx @@ -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 ( +
+ + + +
+ ); +}); + +type Cell = CellContext & { selected?: boolean }; export const useTable = () => { const explorer = useExplorerContext(); - const explorerStore = useExplorerStore(); const [columnSizing, setColumnSizing] = useState({}); const [columnVisibility, setColumnVisibility] = useState({}); @@ -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 ( -
- - - -
- ); - } + cell: ({ row, selected }: Cell) => ( + + ) }, { id: 'kind', @@ -132,7 +141,7 @@ export const useTable = () => { } } ], - [explorerStore.cutCopyState] + [] ); const table = useReactTable({ diff --git a/interface/app/$libraryId/Explorer/View/MediaView.tsx b/interface/app/$libraryId/Explorer/View/MediaView.tsx deleted file mode 100644 index 4031e4e98..000000000 --- a/interface/app/$libraryId/Explorer/View/MediaView.tsx +++ /dev/null @@ -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 ( - - ); -}); - -export default () => { - return ( - - {({ item, selected, cut }) => ( - - )} - - ); -}; diff --git a/interface/app/$libraryId/Explorer/View/MediaView/Item.tsx b/interface/app/$libraryId/Explorer/View/MediaView/Item.tsx new file mode 100644 index 000000000..554dbf7c2 --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/MediaView/Item.tsx @@ -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 ( + + + + + + ); +}); + +const ItemFileThumb = (props: Pick) => { + const filePath = getItemFilePath(props.data); + + const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable({ + data: props.data + }); + + return ( + + ); +}; diff --git a/interface/app/$libraryId/Explorer/View/MediaView/index.tsx b/interface/app/$libraryId/Explorer/View/MediaView/index.tsx new file mode 100644 index 000000000..83d44d66e --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/MediaView/index.tsx @@ -0,0 +1,20 @@ +import { useExplorerContext } from '../../Context'; +import Grid from '../Grid'; +import { MediaViewItem } from './Item'; + +export const MediaView = () => { + const explorerSettings = useExplorerContext().useSettingsSnapshot(); + + return ( + + {({ item, selected, cut }) => ( + + )} + + ); +}; diff --git a/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx b/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx index d3efbc793..371add27b 100644 --- a/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx +++ b/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx @@ -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 { 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(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 ( ); }; diff --git a/interface/app/$libraryId/Explorer/View/ViewItem.tsx b/interface/app/$libraryId/Explorer/View/ViewItem.tsx index 06f78d853..64b171040 100644 --- a/interface/app/$libraryId/Explorer/View/ViewItem.tsx +++ b/interface/app/$libraryId/Explorer/View/ViewItem.tsx @@ -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 { +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 ( doubleClick(data)} {...props}> +
doubleClick(data)}> {children}
} - onOpenChange={explorerView.setIsContextMenuOpen} + onOpenChange={(open) => (getExplorerStore().isContextMenuOpen = open)} disabled={explorerView.contextMenu === undefined} onMouseDown={(e) => e.stopPropagation()} > {explorerView.contextMenu}
); -}); +}; diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 95a30f4ba..6ce612eb2 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -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 { 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(null); + const [{ path }] = useExplorerSearchParams(); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const [isRenaming, setIsRenaming] = useState(false); - const [showLoading, setShowLoading] = useState(false); + const ref = useRef(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 ( - <> -
{ - 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 ( + +
{ + if (e.button === 2 || (e.button === 0 && e.shiftKey)) return; + explorer.selectedItems.size !== 0 && explorer.resetSelectedItems(); + }} + > +
{explorer.items === null || (explorer.items && explorer.items.length > 0) ? ( - + <> {layoutMode === 'grid' && } {layoutMode === 'list' && } {layoutMode === 'media' && } {showLoading && ( )} - + ) : ( emptyNotice )}
+
- {quickPreview.ref && createPortal(, quickPreview.ref)} - - ); - } -); + {/* TODO: Move when adding columns view */} + -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 ; - }; - - if (props.loading) return null; - - return ( -
- {props.icon - ? isValidElement(props.icon) - ? props.icon - : emptyNoticeIcon(props.icon as Icon) - : emptyNoticeIcon()} - -

- {props.message !== undefined ? props.message : 'This list is empty'} -

-
+ {quickPreview.ref && createPortal(, quickPreview.ref)} +
); }; -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) => ( - ({ 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) => ( + ({ type: 'Object', item }))} /> + )); + }); }; diff --git a/interface/app/$libraryId/Explorer/View/useDragScrollable.tsx b/interface/app/$libraryId/Explorer/View/useDragScrollable.tsx new file mode 100644 index 000000000..fe59967da --- /dev/null +++ b/interface/app/$libraryId/Explorer/View/useDragScrollable.tsx @@ -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(null); + + const timeout = useRef(null); + const interval = useRef(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 }; +}; diff --git a/interface/app/$libraryId/Explorer/View/util.ts b/interface/app/$libraryId/Explorer/View/util.ts deleted file mode 100644 index 0ca5488ca..000000000 --- a/interface/app/$libraryId/Explorer/View/util.ts +++ /dev/null @@ -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') - }; -}; diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index 93c692649..79bf07161 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -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) { useKeyRevealFinder(); + useExplorerDnd(); + const topBar = useTopBarContext(); return ( <> -
-
- {explorer.items && explorer.items.length > 0 && } +
+ {explorer.items && explorer.items.length > 0 && } - } - emptyNotice={ - props.emptyNotice ?? ( - - ) - } - listViewOptions={{ hideHeaderBorder: true }} - bottom={showPathBar ? PATH_BAR_HEIGHT : undefined} - /> -
+ } + emptyNotice={ + props.emptyNotice ?? ( + + ) + } + listViewOptions={{ hideHeaderBorder: true }} + bottom={showPathBar ? PATH_BAR_HEIGHT : undefined} + />
+ {showPathBar && } {explorerStore.showInspector && ( diff --git a/interface/app/$libraryId/Explorer/store.ts b/interface/app/$libraryId/Explorer/store.ts index 6c3324387..c90a0bc6c 100644 --- a/interface/app/$libraryId/Explorer/store.ts +++ b/interface/app/$libraryId/Explorer/store.ts @@ -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 = (args?: { } }) satisfies ExplorerSettings; -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, 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) { +export function isCut(item: ExplorerItem, cutCopyState: CutCopyState) { switch (item.type) { case 'NonIndexedPath': return ( diff --git a/interface/app/$libraryId/Explorer/useExplorerDnd.tsx b/interface/app/$libraryId/Explorer/useExplorerDnd.tsx new file mode 100644 index 000000000..893276dc2 --- /dev/null +++ b/interface/app/$libraryId/Explorer/useExplorerDnd.tsx @@ -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 + ); +}; + +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 ( + props.onConfirm({ dismissNotice: dismissNotice }))} + dialog={useDialog(props)} + title="Move Files" + icon={} + description={ + + Are you sure you want to move {props.count} file{props.count > 1 ? 's' : ''} to{' '} + {props.path}? + + } + ctaDanger + ctaLabel="Continue" + closeLabel="Cancel" + buttonsSideContent={ + typeof val === 'boolean' && setDismissNotice(val)} + /> + } + /> + ); +}; diff --git a/interface/app/$libraryId/Explorer/useExplorerDraggable.tsx b/interface/app/$libraryId/Explorer/useExplorerDraggable.tsx new file mode 100644 index 000000000..d34ca14cb --- /dev/null +++ b/interface/app/$libraryId/Explorer/useExplorerDraggable.tsx @@ -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 { + 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, + style + }; +}; diff --git a/interface/app/$libraryId/Explorer/useExplorerDroppable.tsx b/interface/app/$libraryId/Explorer/useExplorerDroppable.tsx new file mode 100644 index 000000000..e808e5337 --- /dev/null +++ b/interface/app/$libraryId/Explorer/useExplorerDroppable.tsx @@ -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 { + id?: string; + data?: + | { + type: 'location'; + data?: Location | z.infer['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, + z.ZodLiteral, + ...z.ZodLiteral[] + ] + ) + : 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 }; +}; diff --git a/interface/app/$libraryId/Layout/DndContext.tsx b/interface/app/$libraryId/Layout/DndContext.tsx new file mode 100644 index 000000000..df0c1266e --- /dev/null +++ b/interface/app/$libraryId/Layout/DndContext.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/Devices/index.tsx b/interface/app/$libraryId/Layout/Sidebar/Devices/index.tsx new file mode 100644 index 000000000..27cbf0a43 --- /dev/null +++ b/interface/app/$libraryId/Layout/Sidebar/Devices/index.tsx @@ -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 ( +
+ + + ) + } + > + {node && ( + + + {node.name} + + )} + + + + +
+ ); +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx b/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx index 74d615d64..a3fc9577a 100644 --- a/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx @@ -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 = () => { Network + {homeDir.data && ( - Home - + )} + {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 ( - { /> {name} {item.volume.disk_type === 'Removable' && } - + ); })} ); }; + +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 ( + + {children} + + ); +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx index 46ceccfa9..ca41374d8 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx @@ -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 = () => ( - <> - - - - - -); - -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 ( -
- // - // - // } - > - - {savedSearches.data.map((search, i) => ( - -
- -
- - {search.name} - - -
- ))} -
-
+ <> + + + + + ); -} - -function Devices() { - const node = useBridgeQuery(['nodeState']); - const isPairingEnabled = useFeatureFlag('p2pPairing'); - - return ( -
- - - ) - } - > - {node.data && ( - - - {node.data.name} - - )} - - - -
- ); -} - -function Locations() { - const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); - useNodes(locationsQuery.data?.nodes); - const locations = useCache(locationsQuery.data?.items); - const onlineLocations = useOnlineLocations(); - - return ( -
- - - } - > - - {locations?.map((location) => ( - - -
- -
arraysEqual(location.pub_id, l)) - ? 'bg-green-500' - : 'bg-red-500' - )} - /> -
- - {location.name} - - - ))} - - -
- ); -} - -function Tags() { - const result = useLibraryQuery(['tags.list'], { keepPreviousData: true }); - useNodes(result.data?.nodes); - const tags = useCache(result.data?.items); - - if (!tags?.length) return; - - return ( -
- - - } - > - - {tags?.map((tag) => ( - - -
- {tag.name} - - - ))} - -
- ); -} +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/LocationsContextMenu.tsx b/interface/app/$libraryId/Layout/Sidebar/Locations/ContextMenu.tsx similarity index 91% rename from interface/app/$libraryId/Layout/Sidebar/LocationsContextMenu.tsx rename to interface/app/$libraryId/Layout/Sidebar/Locations/ContextMenu.tsx index 1bbe6d541..a9b17c73b 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LocationsContextMenu.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Locations/ContextMenu.tsx @@ -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; diff --git a/interface/app/$libraryId/Layout/Sidebar/Locations/index.tsx b/interface/app/$libraryId/Layout/Sidebar/Locations/index.tsx new file mode 100644 index 000000000..bf0645e4b --- /dev/null +++ b/interface/app/$libraryId/Layout/Sidebar/Locations/index.tsx @@ -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 ( +
+ + + } + > + + {locations?.map((location) => ( + arraysEqual(location.pub_id, l))} + /> + ))} + + +
+ ); +}; + +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 ( + + +
+ +
+
+ + {location.name} + + + ); +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/SavedSearches/index.tsx b/interface/app/$libraryId/Layout/Sidebar/SavedSearches/index.tsx new file mode 100644 index 000000000..5b85ca75e --- /dev/null +++ b/interface/app/$libraryId/Layout/Sidebar/SavedSearches/index.tsx @@ -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 ( +
+ // + // + // } + > + + {savedSearches.data.map((search, i) => ( + deleteSavedSearch.mutate(search.id)} + /> + ))} + +
+ ); +}; + +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 ( + +
+ +
+ + {search.name} + + +
+ ); +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/TagsContextMenu.tsx b/interface/app/$libraryId/Layout/Sidebar/Tags/ContextMenu.tsx similarity index 88% rename from interface/app/$libraryId/Layout/Sidebar/TagsContextMenu.tsx rename to interface/app/$libraryId/Layout/Sidebar/Tags/ContextMenu.tsx index 0b92b5673..f968c87de 100644 --- a/interface/app/$libraryId/Layout/Sidebar/TagsContextMenu.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Tags/ContextMenu.tsx @@ -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 ( { + const result = useLibraryQuery(['tags.list'], { keepPreviousData: true }); + useNodes(result.data?.nodes); + const tags = useCache(result.data?.items); + + if (!tags?.length) return null; + + return ( +
+ + + } + > + + {tags.map((tag) => ( + + ))} + +
+ ); +}; + +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 ( + + +
+ {tag.name} + + + ); +}; diff --git a/interface/app/$libraryId/Layout/index.tsx b/interface/app/$libraryId/Layout/index.tsx index 72d379f4c..a1536ed20 100644 --- a/interface/app/$libraryId/Layout/index.tsx +++ b/interface/app/$libraryId/Layout/index.tsx @@ -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(); }} > - -
- {library ? ( - - - }> - - - - - ) : ( -

- Please select or create a library in the sidebar. -

- )} -
+ + +
+ {library ? ( + + + } + > + + + + + + ) : ( +

+ Please select or create a library in the sidebar. +

+ )} +
+
); diff --git a/interface/app/$libraryId/TopBar/Layout.tsx b/interface/app/$libraryId/TopBar/Layout.tsx index 6456a183d..807087e36 100644 --- a/interface/app/$libraryId/TopBar/Layout.tsx +++ b/interface/app/$libraryId/TopBar/Layout.tsx @@ -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 | null>(null); @@ -33,6 +34,13 @@ function useContextValue() { export const Component = () => { const value = useContextValue(); + // Reset drag state + useEffect(() => { + return () => { + getExplorerStore().drag = null; + }; + }, []); + return ( diff --git a/interface/app/$libraryId/TopBar/NavigationButtons.tsx b/interface/app/$libraryId/TopBar/NavigationButtons.tsx index f3f94409d..01e032b56 100644 --- a/interface/app/$libraryId/TopBar/NavigationButtons.tsx +++ b/interface/app/$libraryId/TopBar/NavigationButtons.tsx @@ -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 = () => { navigate(-1)} disabled={!canGoBack} + ref={droppableBack.setDroppableRef} + className={clsx( + droppableBack.isDroppable && '!bg-app-selected', + droppableBack.className + )} > @@ -59,9 +75,13 @@ export const NavigationButtons = () => { navigate(1)} disabled={!canGoForward} + ref={droppableForward.setDroppableRef} + className={clsx( + droppableForward.isDroppable && '!bg-app-selected', + droppableForward.className + )} > diff --git a/interface/app/$libraryId/TopBar/index.tsx b/interface/app/$libraryId/TopBar/index.tsx index 5dc2a521a..cc491dff3 100644 --- a/interface/app/$libraryId/TopBar/index.tsx +++ b/interface/app/$libraryId/TopBar/index.tsx @@ -15,7 +15,7 @@ import { NavigationButtons } from './NavigationButtons'; const TopBar = () => { const transparentBg = useShowControls().transparentBg; - const { isDragging } = useExplorerStore(); + const { isDragSelecting } = useExplorerStore(); const ref = useRef(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' )} >
{ + return (tagId: number, items: AssignTagItems, unassign: boolean = false) => { const targets = items.map((item) => { if (item.type === 'Object') { return { Object: item.item.id }; diff --git a/interface/app/$libraryId/tag/$id.tsx b/interface/app/$libraryId/tag/$id.tsx index d8ac07d3e..7fc370e4b 100644 --- a/interface/app/$libraryId/tag/$id.tsx +++ b/interface/app/$libraryId/tag/$id.tsx @@ -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'; diff --git a/interface/hooks/index.ts b/interface/hooks/index.ts index 41aafbbc9..9afbfec3b 100644 --- a/interface/hooks/index.ts +++ b/interface/hooks/index.ts @@ -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'; diff --git a/interface/hooks/useDismissibleNoticeStore.tsx b/interface/hooks/useDismissibleNoticeStore.tsx index 4d0cbf32f..72fc18004 100644 --- a/interface/hooks/useDismissibleNoticeStore.tsx +++ b/interface/hooks/useDismissibleNoticeStore.tsx @@ -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() { diff --git a/interface/hooks/useDragSelect.tsx b/interface/hooks/useDragSelect.tsx deleted file mode 100644 index 2f624bd9e..000000000 --- a/interface/hooks/useDragSelect.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import DragSelect from 'dragselect'; -import React, { createContext, useContext, useEffect, useState } from 'react'; - -type ProviderProps = { - children: React.ReactNode; - settings?: ConstructorParameters[0]; -}; - -const Context = createContext(undefined); - -function DragSelectProvider({ children, settings = {} }: ProviderProps) { - const [ds, setDS] = useState(); - - 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 {children}; -} - -function useDragSelect() { - return useContext(Context); -} - -export { DragSelectProvider, useDragSelect }; diff --git a/interface/package.json b/interface/package.json index 05b9ff2fb..68c22b971 100644 --- a/interface/package.json +++ b/interface/package.json @@ -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", diff --git a/packages/assets/icons/MoveLocation.png b/packages/assets/icons/MoveLocation.png new file mode 100644 index 000000000..27be2bc94 Binary files /dev/null and b/packages/assets/icons/MoveLocation.png differ diff --git a/packages/assets/icons/MoveLocation_Light.png b/packages/assets/icons/MoveLocation_Light.png new file mode 100644 index 000000000..18b401ea4 Binary files /dev/null and b/packages/assets/icons/MoveLocation_Light.png differ diff --git a/packages/assets/icons/index.ts b/packages/assets/icons/index.ts index ea881c3f1..a18232d4a 100644 --- a/packages/assets/icons/index.ts +++ b/packages/assets/icons/index.ts @@ -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, diff --git a/packages/ui/src/Dialog.tsx b/packages/ui/src/Dialog.tsx index 8be1f90f5..a9e69ea8d 100644 --- a/packages/ui/src/Dialog.tsx +++ b/packages/ui/src/Dialog.tsx @@ -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 ctaDanger?: boolean; closeLabel?: string; cancelBtn?: boolean; - description?: string; + description?: ReactNode; onCancelled?: boolean | (() => void); submitDisabled?: boolean; transformOrigin?: string; @@ -250,7 +249,7 @@ export function Dialog({
{form.formState.isSubmitting && } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94829086c..66e9d84d3 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ