mirror of
https://github.com/enso-org/enso.git
synced 2024-12-28 11:53:16 +03:00
Move some Drive state to zustand (#10913)
- Move Asset Panel open state to zustand - This avoids re-rendering the entire Data Catalog view every time an asset is clicked - Move Asset Panel props to zustand - Move search suggestions to zustand - Memoize `AssetRow` to avoid re-rendering every row when unnecessary # Important Notes It's not a perfect solution to the performance issues, however selection performance seems to have improved quite nicely.
This commit is contained in:
parent
9ec60299e4
commit
51733ee876
@ -46,6 +46,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
onDragCancelRef.current = onDragCancel
|
||||
const lastMouseEvent = React.useRef<MouseEvent | null>(null)
|
||||
const parentBounds = React.useRef<DOMRect | null>(null)
|
||||
const anchorRef = React.useRef<geometry.Coordinate2D | null>(null)
|
||||
const [anchor, setAnchor] = React.useState<geometry.Coordinate2D | null>(null)
|
||||
const [position, setPosition] = React.useState<geometry.Coordinate2D | null>(null)
|
||||
const [lastSetAnchor, setLastSetAnchor] = React.useState<geometry.Coordinate2D | null>(null)
|
||||
@ -74,6 +75,12 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
return eventModule.isElementInBounds(event, parentBounds.current, margin)
|
||||
}
|
||||
}
|
||||
const unsetAnchor = () => {
|
||||
if (anchorRef.current != null) {
|
||||
anchorRef.current = null
|
||||
setAnchor(null)
|
||||
}
|
||||
}
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
modalRef.current == null &&
|
||||
@ -86,7 +93,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
didMoveWhileDraggingRef.current = false
|
||||
lastMouseEvent.current = event
|
||||
const newAnchor = { left: event.pageX, top: event.pageY }
|
||||
setAnchor(newAnchor)
|
||||
anchorRef.current = null
|
||||
setLastSetAnchor(newAnchor)
|
||||
setPosition(newAnchor)
|
||||
}
|
||||
@ -101,7 +108,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
isMouseDownRef.current = false
|
||||
didMoveWhileDraggingRef.current = false
|
||||
})
|
||||
setAnchor(null)
|
||||
unsetAnchor()
|
||||
}
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!(event.buttons & 1)) {
|
||||
@ -137,7 +144,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
if (isMouseDownRef.current) {
|
||||
isMouseDownRef.current = false
|
||||
onDragCancelRef.current()
|
||||
setAnchor(null)
|
||||
unsetAnchor()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,12 @@ import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
|
||||
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
|
||||
import {
|
||||
useDriveStore,
|
||||
useSetAssetPanelProps,
|
||||
useSetIsAssetPanelTemporarilyVisible,
|
||||
useSetSelectedKeys,
|
||||
} from '#/providers/DriveProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -33,6 +38,7 @@ import * as backendModule from '#/services/Backend'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
@ -72,27 +78,47 @@ export interface AssetRowInnerProps {
|
||||
}
|
||||
|
||||
/** Props for an {@link AssetRow}. */
|
||||
export interface AssetRowProps
|
||||
extends Readonly<Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'>> {
|
||||
export interface AssetRowProps {
|
||||
readonly isOpened: boolean
|
||||
readonly item: assetTreeNode.AnyAssetTreeNode
|
||||
readonly state: assetsTable.AssetsTableState
|
||||
readonly hidden: boolean
|
||||
readonly columns: columnUtils.Column[]
|
||||
readonly isKeyboardSelected: boolean
|
||||
readonly grabKeyboardFocus: () => void
|
||||
readonly grabKeyboardFocus: (item: assetTreeNode.AnyAssetTreeNode) => void
|
||||
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
|
||||
readonly select: () => void
|
||||
readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void>
|
||||
readonly select: (item: assetTreeNode.AnyAssetTreeNode) => void
|
||||
readonly onDragStart?: (
|
||||
event: React.DragEvent<HTMLTableRowElement>,
|
||||
item: assetTreeNode.AnyAssetTreeNode,
|
||||
) => void
|
||||
readonly onDragOver?: (
|
||||
event: React.DragEvent<HTMLTableRowElement>,
|
||||
item: assetTreeNode.AnyAssetTreeNode,
|
||||
) => void
|
||||
readonly onDragLeave?: (
|
||||
event: React.DragEvent<HTMLTableRowElement>,
|
||||
item: assetTreeNode.AnyAssetTreeNode,
|
||||
) => void
|
||||
readonly onDragEnd?: (
|
||||
event: React.DragEvent<HTMLTableRowElement>,
|
||||
item: assetTreeNode.AnyAssetTreeNode,
|
||||
) => void
|
||||
readonly onDrop?: (
|
||||
event: React.DragEvent<HTMLTableRowElement>,
|
||||
item: assetTreeNode.AnyAssetTreeNode,
|
||||
) => void
|
||||
readonly updateAssetRef: React.RefObject<
|
||||
Record<backendModule.AssetId, (asset: backendModule.AnyAsset) => void>
|
||||
>
|
||||
}
|
||||
|
||||
/** A row containing an {@link backendModule.AnyAsset}. */
|
||||
export default function AssetRow(props: AssetRowProps) {
|
||||
export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
const { isKeyboardSelected, isOpened, select, state, columns, onClick } = props
|
||||
const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
|
||||
const {
|
||||
nodeMap,
|
||||
setAssetPanelProps,
|
||||
doToggleDirectoryExpansion,
|
||||
doCopy,
|
||||
doCut,
|
||||
@ -102,13 +128,14 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
doMove,
|
||||
category,
|
||||
} = state
|
||||
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
|
||||
const { scrollContainerRef, rootDirectoryId, backend } = state
|
||||
const { visibilities } = state
|
||||
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const driveStore = useDriveStore()
|
||||
const { user } = useFullUserSession()
|
||||
const setSelectedKeys = useSetSelectedKeys()
|
||||
const setAssetPanelProps = useSetAssetPanelProps()
|
||||
const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) =>
|
||||
(visuallySelectedKeys ?? selectedKeys).has(item.key),
|
||||
)
|
||||
@ -129,8 +156,8 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
|
||||
const rootRef = React.useRef<HTMLElement | null>(null)
|
||||
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
||||
const grabKeyboardFocusRef = React.useRef(grabKeyboardFocus)
|
||||
grabKeyboardFocusRef.current = grabKeyboardFocus
|
||||
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
|
||||
const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus)
|
||||
const asset = item.item
|
||||
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
|
||||
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
|
||||
@ -192,11 +219,22 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
React.useEffect(() => {
|
||||
if (isKeyboardSelected) {
|
||||
rootRef.current?.focus()
|
||||
grabKeyboardFocusRef.current()
|
||||
grabKeyboardFocusRef.current(item)
|
||||
}
|
||||
}, [isKeyboardSelected])
|
||||
}, [grabKeyboardFocusRef, isKeyboardSelected, item])
|
||||
|
||||
React.useImperativeHandle(updateAssetRef, () => setAsset)
|
||||
React.useImperativeHandle(updateAssetRef, () => ({ setAsset, item }))
|
||||
if (updateAssetRef.current) {
|
||||
updateAssetRef.current[item.item.id] = setAsset
|
||||
}
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (updateAssetRef.current) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps, @typescript-eslint/no-dynamic-delete
|
||||
delete updateAssetRef.current[item.item.id]
|
||||
}
|
||||
}
|
||||
}, [item.item.id, updateAssetRef])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSoleSelected) {
|
||||
@ -616,7 +654,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!selected) {
|
||||
select()
|
||||
select(item)
|
||||
}
|
||||
setModal(
|
||||
<AssetContextMenu
|
||||
@ -644,7 +682,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
) {
|
||||
event.preventDefault()
|
||||
} else {
|
||||
props.onDragStart?.(event)
|
||||
props.onDragStart?.(event, item)
|
||||
}
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
@ -657,19 +695,19 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}, DRAG_EXPAND_DELAY_MS)
|
||||
}
|
||||
// Required because `dragover` does not fire on `mouseenter`.
|
||||
props.onDragOver?.(event)
|
||||
props.onDragOver?.(event, item)
|
||||
onDragOver(event)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (state.category.type === 'trash') {
|
||||
event.dataTransfer.dropEffect = 'none'
|
||||
}
|
||||
props.onDragOver?.(event)
|
||||
props.onDragOver?.(event, item)
|
||||
onDragOver(event)
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
clearDragState()
|
||||
props.onDragEnd?.(event)
|
||||
props.onDragEnd?.(event, item)
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (
|
||||
@ -685,11 +723,11 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
) {
|
||||
clearDragState()
|
||||
}
|
||||
props.onDragLeave?.(event)
|
||||
props.onDragLeave?.(event, item)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (state.category.type !== 'trash') {
|
||||
props.onDrop?.(event)
|
||||
props.onDrop?.(event, item)
|
||||
clearDragState()
|
||||
const [directoryKey, directoryId] =
|
||||
item.type === backendModule.AssetType.directory ?
|
||||
@ -816,4 +854,4 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -10,6 +10,7 @@ import EditableSpan from '#/components/EditableSpan'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import { useSetIsAssetPanelTemporarilyVisible } from '#/providers/DriveProvider'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
@ -27,8 +28,8 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps {}
|
||||
* @throws {Error} when the asset is not a {@link backendModule.DatalinkAsset}.
|
||||
* This should never happen. */
|
||||
export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { setIsAssetPanelTemporarilyVisible } = state
|
||||
const { item, setItem, selected, rowState, setRowState, isEditable } = props
|
||||
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.datalink) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -1,23 +1,19 @@
|
||||
/**
|
||||
* @file useEventCallback shim
|
||||
*/
|
||||
/** @file `useEvent` shim. */
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import * as syncRef from '#/hooks/syncRefHooks'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
|
||||
/**
|
||||
* useEvent shim.
|
||||
* `useEvent` shim.
|
||||
* @see https://github.com/reactjs/rfcs/pull/220
|
||||
* @see https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md#internal-implementation
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function useEventCallback<Func extends (...args: any[]) => unknown>(callback: Func) {
|
||||
const callbackRef = syncRef.useSyncRef(callback)
|
||||
export function useEventCallback<Func extends (...args: never[]) => unknown>(callback: Func) {
|
||||
const callbackRef = useSyncRef(callback)
|
||||
|
||||
// Make sure that the value of `this` provided for the call to fn is not `ref`
|
||||
// This type assertion is safe, because it's a transparent wrapper around the original callback
|
||||
// we mute react-hooks/exhaustive-deps because we don't need to update the callback when the callbackRef changes(it never does)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps, no-restricted-syntax
|
||||
return React.useCallback(((...args) => callbackRef.current.apply(void 0, args)) as Func, [])
|
||||
return useCallback(((...args) => callbackRef.current.apply(undefined, args)) as Func, [])
|
||||
}
|
||||
|
@ -1,16 +1,9 @@
|
||||
/**
|
||||
* @file useSyncRef.ts
|
||||
*
|
||||
* A hook that returns a ref object whose `current` property is always in sync with the provided value.
|
||||
*/
|
||||
/** @file A hook that returns a ref object whose `current` property is always in sync with the provided value. */
|
||||
import { type MutableRefObject, useRef } from 'react'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* A hook that returns a ref object whose `current` property is always in sync with the provided value.
|
||||
*/
|
||||
export function useSyncRef<T>(value: T): Readonly<React.MutableRefObject<T>> {
|
||||
const ref = React.useRef(value)
|
||||
/** A hook that returns a ref object whose `current` property is always in sync with the provided value. */
|
||||
export function useSyncRef<T>(value: T): Readonly<MutableRefObject<T>> {
|
||||
const ref = useRef(value)
|
||||
|
||||
/*
|
||||
Even though the react core team doesn't recommend setting ref values during the render (it might lead to deoptimizations), the reasoning behind this is:
|
||||
|
@ -16,6 +16,7 @@ import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import { useAssetPanelProps, useIsAssetPanelVisible } from '#/providers/DriveProvider'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
@ -52,24 +53,28 @@ LocalStorage.register({
|
||||
// === AssetPanel ===
|
||||
// ==================
|
||||
|
||||
/** The subset of {@link AssetPanelProps} that are required to be supplied by the row. */
|
||||
export interface AssetPanelRequiredProps {
|
||||
/** Props supplied by the row. */
|
||||
export interface AssetPanelContextProps {
|
||||
readonly backend: Backend | null
|
||||
readonly item: assetTreeNode.AnyAssetTreeNode | null
|
||||
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>> | null
|
||||
}
|
||||
|
||||
/** Props for an {@link AssetPanel}. */
|
||||
export interface AssetPanelProps extends AssetPanelRequiredProps {
|
||||
readonly isVisible: boolean
|
||||
readonly isReadonly?: boolean
|
||||
export interface AssetPanelProps {
|
||||
readonly backendType: backendModule.BackendType
|
||||
readonly category: Category
|
||||
}
|
||||
|
||||
/** A panel containing the description and settings for an asset. */
|
||||
export default function AssetPanel(props: AssetPanelProps) {
|
||||
const { isVisible, backend, isReadonly = false, item, setItem, category } = props
|
||||
const { backendType, category } = props
|
||||
const contextPropsRaw = useAssetPanelProps()
|
||||
const contextProps = backendType === contextPropsRaw?.backend?.type ? contextPropsRaw : null
|
||||
const { backend, item, setItem } = contextProps ?? {}
|
||||
const isReadonly = category.type === 'trash'
|
||||
const isCloud = backend?.type === backendModule.BackendType.remote
|
||||
const isVisible = useIsAssetPanelVisible()
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
@ -112,82 +117,88 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="asset-panel"
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-none absolute flex h-full w-asset-panel flex-col gap-asset-panel bg-white p-4 pl-asset-panel-l transition-[box-shadow] clip-path-left-shadow',
|
||||
isVisible ? 'shadow-softer' : '',
|
||||
'flex flex-col overflow-hidden transition-min-width duration-side-panel ease-in-out',
|
||||
isVisible ? 'min-w-side-panel' : 'min-w',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<ariaComponents.ButtonGroup className="mt-0.5 grow-0 basis-8">
|
||||
{isCloud &&
|
||||
item != null &&
|
||||
item.item.type !== backendModule.AssetType.secret &&
|
||||
item.item.type !== backendModule.AssetType.directory && (
|
||||
<div
|
||||
data-testid="asset-panel"
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-none absolute flex h-full w-asset-panel flex-col gap-asset-panel bg-white p-4 pl-asset-panel-l transition-[box-shadow] clip-path-left-shadow',
|
||||
isVisible ? 'shadow-softer' : '',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<ariaComponents.ButtonGroup className="mt-0.5 grow-0 basis-8">
|
||||
{isCloud &&
|
||||
item != null &&
|
||||
item.item.type !== backendModule.AssetType.secret &&
|
||||
item.item.type !== backendModule.AssetType.directory && (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="outline"
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-auto disabled:opacity-100',
|
||||
tab === AssetPanelTab.versions && 'bg-primary/[8%] opacity-100',
|
||||
)}
|
||||
onPress={() => {
|
||||
setTab((oldTab) =>
|
||||
oldTab === AssetPanelTab.versions ?
|
||||
AssetPanelTab.properties
|
||||
: AssetPanelTab.versions,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{getText('versions')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
{isCloud && item != null && item.item.type === backendModule.AssetType.project && (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="outline"
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-auto disabled:opacity-100',
|
||||
tab === AssetPanelTab.versions && 'bg-primary/[8%] opacity-100',
|
||||
tab === AssetPanelTab.projectSessions && 'bg-primary/[8%] opacity-100',
|
||||
)}
|
||||
onPress={() => {
|
||||
setTab((oldTab) =>
|
||||
oldTab === AssetPanelTab.versions ?
|
||||
oldTab === AssetPanelTab.projectSessions ?
|
||||
AssetPanelTab.properties
|
||||
: AssetPanelTab.versions,
|
||||
: AssetPanelTab.projectSessions,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{getText('versions')}
|
||||
{getText('projectSessions')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
{isCloud && item != null && item.item.type === backendModule.AssetType.project && (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="outline"
|
||||
isDisabled={tab === AssetPanelTab.projectSessions}
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-auto disabled:opacity-100',
|
||||
tab === AssetPanelTab.projectSessions && 'bg-primary/[8%] opacity-100',
|
||||
{/* Spacing. The top right asset and user bars overlap this area. */}
|
||||
<div className="grow" />
|
||||
</ariaComponents.ButtonGroup>
|
||||
{item == null || setItem == null || backend == null ?
|
||||
<div className="grid grow place-items-center text-lg">
|
||||
{getText('selectExactlyOneAssetToViewItsDetails')}
|
||||
</div>
|
||||
: <>
|
||||
{tab === AssetPanelTab.properties && (
|
||||
<AssetProperties
|
||||
backend={backend}
|
||||
isReadonly={isReadonly}
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
category={category}
|
||||
/>
|
||||
)}
|
||||
onPress={() => {
|
||||
setTab((oldTab) =>
|
||||
oldTab === AssetPanelTab.projectSessions ?
|
||||
AssetPanelTab.properties
|
||||
: AssetPanelTab.projectSessions,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{getText('projectSessions')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
{/* Spacing. The top right asset and user bars overlap this area. */}
|
||||
<div className="grow" />
|
||||
</ariaComponents.ButtonGroup>
|
||||
{item == null || setItem == null || backend == null ?
|
||||
<div className="grid grow place-items-center text-lg">
|
||||
{getText('selectExactlyOneAssetToViewItsDetails')}
|
||||
</div>
|
||||
: <>
|
||||
{tab === AssetPanelTab.properties && (
|
||||
<AssetProperties
|
||||
backend={backend}
|
||||
isReadonly={isReadonly}
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
category={category}
|
||||
/>
|
||||
)}
|
||||
{tab === AssetPanelTab.versions && <AssetVersions backend={backend} item={item} />}
|
||||
{tab === AssetPanelTab.projectSessions &&
|
||||
item.type === backendModule.AssetType.project && (
|
||||
<AssetProjectSessions backend={backend} item={item} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
{tab === AssetPanelTab.versions && <AssetVersions backend={backend} item={item} />}
|
||||
{tab === AssetPanelTab.projectSessions &&
|
||||
item.type === backendModule.AssetType.project && (
|
||||
<AssetProjectSessions backend={backend} item={item} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import { useSuggestions } from '#/providers/DriveProvider'
|
||||
import * as array from '#/utilities/array'
|
||||
import AssetQuery from '#/utilities/AssetQuery'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
@ -120,16 +121,16 @@ export interface AssetSearchBarProps {
|
||||
readonly isCloud: boolean
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly suggestions: readonly Suggestion[]
|
||||
}
|
||||
|
||||
/** A search bar containing a text input, and a list of suggestions. */
|
||||
export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
const { backend, isCloud, query, setQuery, suggestions: rawSuggestions } = props
|
||||
const { backend, isCloud, query, setQuery } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const { modalRef } = modalProvider.useModalRef()
|
||||
/** A cached query as of the start of tabbing. */
|
||||
const baseQuery = React.useRef(query)
|
||||
const rawSuggestions = useSuggestions()
|
||||
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
|
||||
const suggestionsRef = React.useRef(rawSuggestions)
|
||||
const [selectedIndices, setSelectedIndices] = React.useState<ReadonlySet<number>>(
|
||||
|
@ -27,9 +27,12 @@ import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import {
|
||||
useDriveStore,
|
||||
useSetAssetPanelProps,
|
||||
useSetCanCreateAssets,
|
||||
useSetCanDownload,
|
||||
useSetIsAssetPanelTemporarilyVisible,
|
||||
useSetSelectedKeys,
|
||||
useSetTargetDirectory,
|
||||
useSetSuggestions,
|
||||
useSetVisuallySelectedKeys,
|
||||
} from '#/providers/DriveProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
@ -43,15 +46,14 @@ import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import type * as assetPanel from '#/layouts/AssetPanel'
|
||||
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
|
||||
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import { isLocalCategory, type Category } from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import type * as assetRow from '#/components/dashboard/AssetRow'
|
||||
import AssetRow from '#/components/dashboard/AssetRow'
|
||||
import { AssetRow } from '#/components/dashboard/AssetRow'
|
||||
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import NameColumn from '#/components/dashboard/column/NameColumn'
|
||||
@ -249,8 +251,6 @@ export interface AssetsTableState {
|
||||
readonly setSortInfo: (sortInfo: sorting.SortInfo<columnUtils.SortableColumn> | null) => void
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
|
||||
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
|
||||
readonly nodeMap: Readonly<
|
||||
React.MutableRefObject<ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>>
|
||||
>
|
||||
@ -290,13 +290,8 @@ export interface AssetsTableProps {
|
||||
readonly hidden: boolean
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly setSuggestions: React.Dispatch<
|
||||
React.SetStateAction<readonly assetSearchBar.Suggestion[]>
|
||||
>
|
||||
readonly category: Category
|
||||
readonly initialProjectName: string | null
|
||||
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
|
||||
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
|
||||
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
|
||||
readonly assetManagementApiRef: React.Ref<AssetManagementApi>
|
||||
}
|
||||
@ -312,12 +307,12 @@ export interface AssetManagementApi {
|
||||
/** The table of project assets. */
|
||||
export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { hidden, query, setQuery, category, assetManagementApiRef } = props
|
||||
const { setSuggestions, initialProjectName } = props
|
||||
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
const { initialProjectName, targetDirectoryNodeRef } = props
|
||||
|
||||
const openedProjects = projectsProvider.useLaunchedProjects()
|
||||
const doOpenProject = projectHooks.useOpenProject()
|
||||
const setCanDownload = useSetCanDownload()
|
||||
const setSuggestions = useSetSuggestions()
|
||||
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const backend = backendProvider.useBackend(category)
|
||||
@ -331,10 +326,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const previousCategoryRef = React.useRef(category)
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const setTargetDirectoryRaw = useSetTargetDirectory()
|
||||
const setCanCreateAssets = useSetCanCreateAssets()
|
||||
const didLoadingProjectManagerFail = backendProvider.useDidLoadingProjectManagerFail()
|
||||
const reconnectToProjectManager = backendProvider.useReconnectToProjectManager()
|
||||
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
|
||||
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
|
||||
const setAssetPanelProps = useSetAssetPanelProps()
|
||||
|
||||
const hiddenColumns = columnUtils
|
||||
.getColumnList(user, backend.type)
|
||||
.filter((column) => !enabledColumns.has(column))
|
||||
@ -865,9 +863,21 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})
|
||||
|
||||
const setTargetDirectory = useEventCallback(
|
||||
(page: AssetTreeNode<backendModule.DirectoryAsset> | null) => {
|
||||
setTargetDirectoryRaw(page)
|
||||
targetDirectoryNodeRef.current = page
|
||||
(targetDirectory: AssetTreeNode<backendModule.DirectoryAsset> | null) => {
|
||||
const targetDirectorySelfPermission =
|
||||
targetDirectory == null ? null : (
|
||||
permissions.tryFindSelfPermission(user, targetDirectory.item.permissions)
|
||||
)
|
||||
const canCreateAssets =
|
||||
targetDirectory == null ?
|
||||
category.type !== 'cloud' || user.plan == null || user.plan === backendModule.Plan.solo
|
||||
: isLocalCategory(category) ||
|
||||
(targetDirectorySelfPermission != null &&
|
||||
permissions.canPermissionModifyDirectoryContents(
|
||||
targetDirectorySelfPermission.permission,
|
||||
))
|
||||
setCanCreateAssets(canCreateAssets)
|
||||
targetDirectoryNodeRef.current = targetDirectory
|
||||
},
|
||||
)
|
||||
|
||||
@ -1311,14 +1321,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const selectionStartIndexRef = React.useRef<number | null>(null)
|
||||
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
||||
|
||||
const setMostRecentlySelectedIndex = React.useCallback(
|
||||
(index: number | null, isKeyboard = false) => {
|
||||
const setMostRecentlySelectedIndex = useEventCallback(
|
||||
(index: number | null, isKeyboard: boolean = false) => {
|
||||
React.startTransition(() => {
|
||||
mostRecentlySelectedIndexRef.current = index
|
||||
setKeyboardSelectedIndex(isKeyboard ? index : null)
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -1579,7 +1588,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
// This is not a React component, even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const onAssetListEvent = (event: assetListEvent.AssetListEvent) => {
|
||||
const onAssetListEvent = useEventCallback((event: assetListEvent.AssetListEvent) => {
|
||||
switch (event.type) {
|
||||
case AssetListEventType.newFolder: {
|
||||
const parent = nodeMapRef.current.get(event.parentKey)
|
||||
@ -2105,9 +2114,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const onAssetListEventRef = React.useRef(onAssetListEvent)
|
||||
onAssetListEventRef.current = onAssetListEvent
|
||||
})
|
||||
eventListProvider.useAssetListEventListener((event) => {
|
||||
if (!isLoading) {
|
||||
onAssetListEvent(event)
|
||||
@ -2116,13 +2123,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const doCopy = React.useCallback(() => {
|
||||
const doCopy = useEventCallback(() => {
|
||||
unsetModal()
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
setPasteData({ type: PasteType.copy, data: selectedKeys })
|
||||
}, [driveStore, unsetModal])
|
||||
})
|
||||
|
||||
const doCut = React.useCallback(() => {
|
||||
const doCut = useEventCallback(() => {
|
||||
unsetModal()
|
||||
if (pasteData != null) {
|
||||
dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteData.data })
|
||||
@ -2131,9 +2138,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setPasteData({ type: PasteType.move, data: selectedKeys })
|
||||
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeys })
|
||||
setSelectedKeys(new Set())
|
||||
}, [unsetModal, pasteData, driveStore, dispatchAssetEvent, setSelectedKeys])
|
||||
})
|
||||
|
||||
const doPaste = React.useCallback(
|
||||
const doPaste = useEventCallback(
|
||||
(newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId) => {
|
||||
unsetModal()
|
||||
if (pasteData != null) {
|
||||
@ -2163,7 +2170,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[pasteData, doToggleDirectoryExpansion, unsetModal, dispatchAssetEvent, dispatchAssetListEvent],
|
||||
)
|
||||
|
||||
const doRestore = useEventCallback(async (asset: backendModule.AnyAsset) => {
|
||||
@ -2174,9 +2180,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const hideColumn = React.useCallback((column: columnUtils.Column) => {
|
||||
const hideColumn = useEventCallback((column: columnUtils.Column) => {
|
||||
setEnabledColumns((columns) => set.withPresence(columns, column, false))
|
||||
}, [])
|
||||
})
|
||||
|
||||
const hiddenContextMenu = React.useMemo(
|
||||
() => (
|
||||
@ -2242,8 +2248,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setSortInfo,
|
||||
query,
|
||||
setQuery,
|
||||
setAssetPanelProps,
|
||||
setIsAssetPanelTemporarilyVisible,
|
||||
nodeMap: nodeMapRef,
|
||||
pasteData: pasteDataRef,
|
||||
hideColumn,
|
||||
@ -2272,8 +2276,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
doRestore,
|
||||
doMove,
|
||||
hideColumn,
|
||||
setAssetPanelProps,
|
||||
setIsAssetPanelTemporarilyVisible,
|
||||
setQuery,
|
||||
],
|
||||
)
|
||||
@ -2334,14 +2336,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (queuedAssetEvents.length !== 0) {
|
||||
queuedAssetListEventsRef.current = []
|
||||
for (const event of queuedAssetEvents) {
|
||||
onAssetListEventRef.current(event)
|
||||
onAssetListEvent(event)
|
||||
}
|
||||
}
|
||||
setSpinnerState(spinner.SpinnerState.initial)
|
||||
}
|
||||
}, [isLoading])
|
||||
}, [isLoading, onAssetListEvent])
|
||||
|
||||
const calculateNewKeys = React.useCallback(
|
||||
const calculateNewKeys = useEventCallback(
|
||||
(
|
||||
event: MouseEvent | React.MouseEvent,
|
||||
keys: backendModule.AssetId[],
|
||||
@ -2378,14 +2380,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})(event, false)
|
||||
return result
|
||||
},
|
||||
[driveStore, inputBindings],
|
||||
)
|
||||
|
||||
const { startAutoScroll, endAutoScroll, onMouseEvent } = autoScrollHooks.useAutoScroll(rootRef)
|
||||
|
||||
const dragSelectionChangeLoopHandle = React.useRef(0)
|
||||
const dragSelectionRangeRef = React.useRef<DragSelectionInfo | null>(null)
|
||||
const onSelectionDrag = React.useCallback(
|
||||
const onSelectionDrag = useEventCallback(
|
||||
(rectangle: geometry.DetailedRectangle, event: MouseEvent) => {
|
||||
startAutoScroll()
|
||||
onMouseEvent(event)
|
||||
@ -2431,37 +2432,30 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[startAutoScroll, onMouseEvent, setVisuallySelectedKeys, displayItems, calculateNewKeys],
|
||||
)
|
||||
|
||||
const onSelectionDragEnd = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
endAutoScroll()
|
||||
onMouseEvent(event)
|
||||
const range = dragSelectionRangeRef.current
|
||||
if (range != null) {
|
||||
const keys = displayItems.slice(range.start, range.end).map((node) => node.key)
|
||||
setSelectedKeys(calculateNewKeys(event, keys, () => []))
|
||||
}
|
||||
setVisuallySelectedKeys(null)
|
||||
dragSelectionRangeRef.current = null
|
||||
},
|
||||
[
|
||||
endAutoScroll,
|
||||
onMouseEvent,
|
||||
setVisuallySelectedKeys,
|
||||
displayItems,
|
||||
setSelectedKeys,
|
||||
calculateNewKeys,
|
||||
],
|
||||
)
|
||||
|
||||
const onSelectionDragCancel = React.useCallback(() => {
|
||||
const onSelectionDragEnd = useEventCallback((event: MouseEvent) => {
|
||||
endAutoScroll()
|
||||
onMouseEvent(event)
|
||||
const range = dragSelectionRangeRef.current
|
||||
if (range != null) {
|
||||
const keys = displayItems.slice(range.start, range.end).map((node) => node.key)
|
||||
setSelectedKeys(calculateNewKeys(event, keys, () => []))
|
||||
}
|
||||
setVisuallySelectedKeys(null)
|
||||
dragSelectionRangeRef.current = null
|
||||
}, [setVisuallySelectedKeys])
|
||||
})
|
||||
|
||||
const onRowClick = React.useCallback(
|
||||
const onSelectionDragCancel = useEventCallback(() => {
|
||||
setVisuallySelectedKeys(null)
|
||||
dragSelectionRangeRef.current = null
|
||||
})
|
||||
|
||||
const grabRowKeyboardFocus = useEventCallback((item: assetTreeNode.AnyAssetTreeNode) => {
|
||||
setSelectedKeys(new Set([item.key]))
|
||||
})
|
||||
|
||||
const onRowClick = useEventCallback(
|
||||
(innerRowProps: assetRow.AssetRowInnerProps, event: React.MouseEvent) => {
|
||||
const { key } = innerRowProps
|
||||
event.stopPropagation()
|
||||
@ -2483,7 +2477,158 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
selectionStartIndexRef.current = null
|
||||
}
|
||||
},
|
||||
[visibleItems, calculateNewKeys, setSelectedKeys, setMostRecentlySelectedIndex],
|
||||
)
|
||||
|
||||
const selectRow = useEventCallback((item: assetTreeNode.AnyAssetTreeNode) => {
|
||||
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
|
||||
selectionStartIndexRef.current = null
|
||||
setSelectedKeys(new Set([item.key]))
|
||||
})
|
||||
|
||||
const onRowDragStart = useEventCallback(
|
||||
(event: React.DragEvent<HTMLTableRowElement>, item: assetTreeNode.AnyAssetTreeNode) => {
|
||||
startAutoScroll()
|
||||
onMouseEvent(event)
|
||||
let newSelectedKeys = driveStore.getState().selectedKeys
|
||||
if (!newSelectedKeys.has(item.key)) {
|
||||
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
|
||||
selectionStartIndexRef.current = null
|
||||
newSelectedKeys = new Set([item.key])
|
||||
setSelectedKeys(newSelectedKeys)
|
||||
}
|
||||
const nodes = assetTree.preorderTraversal().filter((node) => newSelectedKeys.has(node.key))
|
||||
const payload: drag.AssetRowsDragPayload = nodes.map((node) => ({
|
||||
key: node.key,
|
||||
asset: node.item,
|
||||
}))
|
||||
event.dataTransfer.setData(
|
||||
mimeTypes.ASSETS_MIME_TYPE,
|
||||
JSON.stringify(nodes.map((node) => node.key)),
|
||||
)
|
||||
drag.setDragImageToBlank(event)
|
||||
drag.ASSET_ROWS.bind(event, payload)
|
||||
setModal(
|
||||
<DragModal
|
||||
event={event}
|
||||
className="flex flex-col rounded-default bg-selected-frame backdrop-blur-default"
|
||||
onDragEnd={() => {
|
||||
drag.ASSET_ROWS.unbind(payload)
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<NameColumn
|
||||
key={node.key}
|
||||
isOpened={false}
|
||||
keyProp={node.key}
|
||||
item={node.with({ depth: 0 })}
|
||||
backendType={backend.type}
|
||||
state={state}
|
||||
// Default states.
|
||||
isSoleSelected={false}
|
||||
selected={false}
|
||||
rowState={assetRowUtils.INITIAL_ROW_STATE}
|
||||
// The drag placeholder cannot be interacted with.
|
||||
setSelected={() => {}}
|
||||
setItem={() => {}}
|
||||
setRowState={() => {}}
|
||||
isEditable={false}
|
||||
/>
|
||||
))}
|
||||
</DragModal>,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const onRowDragOver = useEventCallback(
|
||||
(event: React.DragEvent<HTMLTableRowElement>, item: assetTreeNode.AnyAssetTreeNode) => {
|
||||
onMouseEvent(event)
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
const idsReference = selectedKeys.has(item.key) ? selectedKeys : item.key
|
||||
// This optimization is required in order to avoid severe lag on Firefox.
|
||||
if (idsReference !== lastSelectedIdsRef.current) {
|
||||
lastSelectedIdsRef.current = idsReference
|
||||
const ids = typeof idsReference === 'string' ? new Set([idsReference]) : idsReference
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (nodeLabels != null) {
|
||||
for (const label of nodeLabels) {
|
||||
if (payload.has(label)) {
|
||||
labelsPresent += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
|
||||
window.setTimeout(() => {
|
||||
dispatchAssetEvent({
|
||||
type:
|
||||
shouldAdd ?
|
||||
AssetEventType.temporarilyAddLabels
|
||||
: AssetEventType.temporarilyRemoveLabels,
|
||||
ids,
|
||||
labelNames: payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const onRowDragLeave = useEventCallback(() => {
|
||||
setIsDraggingFiles(false)
|
||||
})
|
||||
|
||||
const onRowDragEnd = useEventCallback(() => {
|
||||
setIsDraggingFiles(false)
|
||||
endAutoScroll()
|
||||
lastSelectedIdsRef.current = null
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids: selectedKeys,
|
||||
labelNames: set.EMPTY_SET,
|
||||
})
|
||||
})
|
||||
|
||||
const onRowDrop = useEventCallback(
|
||||
(event: React.DragEvent<HTMLTableRowElement>, item: assetTreeNode.AnyAssetTreeNode) => {
|
||||
endAutoScroll()
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
const ids = new Set(selectedKeys.has(item.key) ? selectedKeys : [item.key])
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (nodeLabels != null) {
|
||||
for (const label of nodeLabels) {
|
||||
if (payload.has(label)) {
|
||||
labelsPresent += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
|
||||
dispatchAssetEvent({
|
||||
type: shouldAdd ? AssetEventType.addLabels : AssetEventType.removeLabels,
|
||||
ids,
|
||||
labelNames: payload,
|
||||
})
|
||||
} else {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids,
|
||||
labelNames: set.EMPTY_SET,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const getAsset = useEventCallback(
|
||||
@ -2513,9 +2658,11 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setAsset,
|
||||
}))
|
||||
|
||||
const columns = columnUtils
|
||||
.getColumnList(user, backend.type)
|
||||
.filter((column) => enabledColumns.has(column))
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
columnUtils.getColumnList(user, backend.type).filter((column) => enabledColumns.has(column)),
|
||||
[backend.type, enabledColumns, user],
|
||||
)
|
||||
|
||||
const headerRow = (
|
||||
<tr ref={headerRowRef} className="sticky top-[1px] text-sm font-semibold">
|
||||
@ -2541,20 +2688,11 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
: displayItems.map((item, i) => {
|
||||
: displayItems.map((item) => {
|
||||
return (
|
||||
<AssetRow
|
||||
key={item.key + item.path}
|
||||
updateAssetRef={(instance) => {
|
||||
if (instance != null) {
|
||||
updateAssetRef.current[item.item.id] = instance
|
||||
} else {
|
||||
// Hacky way to clear the reference to the asset on unmount.
|
||||
// eventually once we pull the assets up in the tree, we can remove this.
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete updateAssetRef.current[item.item.id]
|
||||
}
|
||||
}}
|
||||
updateAssetRef={updateAssetRef}
|
||||
isOpened={openedProjects.some(({ id }) => item.item.id === id)}
|
||||
columns={columns}
|
||||
item={item}
|
||||
@ -2563,156 +2701,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
isKeyboardSelected={
|
||||
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
|
||||
}
|
||||
grabKeyboardFocus={() => {
|
||||
setSelectedKeys(new Set([item.key]))
|
||||
setMostRecentlySelectedIndex(i, true)
|
||||
}}
|
||||
grabKeyboardFocus={grabRowKeyboardFocus}
|
||||
onClick={onRowClick}
|
||||
select={() => {
|
||||
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
|
||||
selectionStartIndexRef.current = null
|
||||
setSelectedKeys(new Set([item.key]))
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
startAutoScroll()
|
||||
onMouseEvent(event)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
let newSelectedKeys = driveStore.getState().selectedKeys
|
||||
if (!newSelectedKeys.has(item.key)) {
|
||||
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
|
||||
selectionStartIndexRef.current = null
|
||||
newSelectedKeys = new Set([item.key])
|
||||
setSelectedKeys(newSelectedKeys)
|
||||
}
|
||||
const nodes = assetTree
|
||||
.preorderTraversal()
|
||||
.filter((node) => newSelectedKeys.has(node.key))
|
||||
const payload: drag.AssetRowsDragPayload = nodes.map((node) => ({
|
||||
key: node.key,
|
||||
asset: node.item,
|
||||
}))
|
||||
event.dataTransfer.setData(
|
||||
mimeTypes.ASSETS_MIME_TYPE,
|
||||
JSON.stringify(nodes.map((node) => node.key)),
|
||||
)
|
||||
drag.setDragImageToBlank(event)
|
||||
drag.ASSET_ROWS.bind(event, payload)
|
||||
setModal(
|
||||
<DragModal
|
||||
event={event}
|
||||
className="flex flex-col rounded-default bg-selected-frame backdrop-blur-default"
|
||||
onDragEnd={() => {
|
||||
drag.ASSET_ROWS.unbind(payload)
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<NameColumn
|
||||
key={node.key}
|
||||
isOpened={false}
|
||||
keyProp={node.key}
|
||||
item={node.with({ depth: 0 })}
|
||||
backendType={backend.type}
|
||||
state={state}
|
||||
// Default states.
|
||||
isSoleSelected={false}
|
||||
selected={false}
|
||||
rowState={assetRowUtils.INITIAL_ROW_STATE}
|
||||
// The drag placeholder cannot be interacted with.
|
||||
setSelected={() => {}}
|
||||
setItem={() => {}}
|
||||
setRowState={() => {}}
|
||||
isEditable={false}
|
||||
/>
|
||||
))}
|
||||
</DragModal>,
|
||||
)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
onMouseEvent(event)
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
const idsReference = selectedKeys.has(item.key) ? selectedKeys : item.key
|
||||
// This optimization is required in order to avoid severe lag on Firefox.
|
||||
if (idsReference !== lastSelectedIdsRef.current) {
|
||||
lastSelectedIdsRef.current = idsReference
|
||||
const ids =
|
||||
typeof idsReference === 'string' ? new Set([idsReference]) : idsReference
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (nodeLabels != null) {
|
||||
for (const label of nodeLabels) {
|
||||
if (payload.has(label)) {
|
||||
labelsPresent += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
|
||||
window.setTimeout(() => {
|
||||
dispatchAssetEvent({
|
||||
type:
|
||||
shouldAdd ?
|
||||
AssetEventType.temporarilyAddLabels
|
||||
: AssetEventType.temporarilyRemoveLabels,
|
||||
ids,
|
||||
labelNames: payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setIsDraggingFiles(false)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setIsDraggingFiles(false)
|
||||
endAutoScroll()
|
||||
lastSelectedIdsRef.current = null
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids: selectedKeys,
|
||||
labelNames: set.EMPTY_SET,
|
||||
})
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
setIsDraggingFiles(false)
|
||||
endAutoScroll()
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
const ids = new Set(selectedKeys.has(item.key) ? selectedKeys : [item.key])
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (nodeLabels != null) {
|
||||
for (const label of nodeLabels) {
|
||||
if (payload.has(label)) {
|
||||
labelsPresent += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
|
||||
dispatchAssetEvent({
|
||||
type: shouldAdd ? AssetEventType.addLabels : AssetEventType.removeLabels,
|
||||
ids,
|
||||
labelNames: payload,
|
||||
})
|
||||
} else {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids,
|
||||
labelNames: set.EMPTY_SET,
|
||||
})
|
||||
}
|
||||
}}
|
||||
select={selectRow}
|
||||
onDragStart={onRowDragStart}
|
||||
onDragOver={onRowDragOver}
|
||||
onDragLeave={onRowDragLeave}
|
||||
onDragEnd={onRowDragEnd}
|
||||
onDrop={onRowDrop}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
@ -10,14 +10,11 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import type * as assetPanel from '#/layouts/AssetPanel'
|
||||
import AssetPanel from '#/layouts/AssetPanel'
|
||||
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
|
||||
import type * as assetsTable from '#/layouts/AssetsTable'
|
||||
import AssetsTable from '#/layouts/AssetsTable'
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
@ -31,6 +28,7 @@ import * as result from '#/components/Result'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
|
||||
import AssetQuery from '#/utilities/AssetQuery'
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as download from '#/utilities/download'
|
||||
@ -56,7 +54,6 @@ export default function Drive(props: DriveProps) {
|
||||
const { category, setCategory, hidden, initialProjectName, assetsManagementApiRef } = props
|
||||
|
||||
const { isOffline } = offlineHooks.useOffline()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
@ -64,21 +61,12 @@ export default function Drive(props: DriveProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
|
||||
const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([])
|
||||
const [assetPanelPropsRaw, setAssetPanelProps] =
|
||||
React.useState<assetPanel.AssetPanelRequiredProps | null>(null)
|
||||
const assetPanelProps =
|
||||
backend.type === assetPanelPropsRaw?.backend?.type ? assetPanelPropsRaw : null
|
||||
const [isAssetPanelEnabled, setIsAssetPanelEnabled] = React.useState(
|
||||
() => localStorage.get('isAssetPanelVisible') ?? false,
|
||||
)
|
||||
const [isAssetPanelTemporarilyVisible, setIsAssetPanelTemporarilyVisible] = React.useState(false)
|
||||
const organizationQuery = useSuspenseQuery({
|
||||
queryKey: [backend.type, 'getOrganization'],
|
||||
queryFn: () => backend.getOrganization(),
|
||||
})
|
||||
const organization = organizationQuery.data ?? null
|
||||
const [localRootDirectory] = localStorageProvider.useLocalStorageState('localRootDirectory')
|
||||
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
|
||||
const rootDirectoryId = React.useMemo(() => {
|
||||
switch (category.type) {
|
||||
case 'user':
|
||||
@ -105,12 +93,6 @@ export default function Drive(props: DriveProps) {
|
||||
: isCloud && !user.isEnabled ? 'not-enabled'
|
||||
: 'ok'
|
||||
|
||||
const isAssetPanelVisible = isAssetPanelEnabled || isAssetPanelTemporarilyVisible
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.set('isAssetPanelVisible', isAssetPanelEnabled)
|
||||
}, [isAssetPanelEnabled, /* should never change */ localStorage])
|
||||
|
||||
const doUploadFiles = React.useCallback(
|
||||
(files: File[]) => {
|
||||
if (isCloud && isOffline) {
|
||||
@ -234,17 +216,7 @@ export default function Drive(props: DriveProps) {
|
||||
backend={backend}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
suggestions={suggestions}
|
||||
category={category}
|
||||
isAssetPanelOpen={isAssetPanelVisible}
|
||||
setIsAssetPanelOpen={(valueOrUpdater) => {
|
||||
const newValue =
|
||||
typeof valueOrUpdater === 'function' ?
|
||||
valueOrUpdater(isAssetPanelVisible)
|
||||
: valueOrUpdater
|
||||
setIsAssetPanelTemporarilyVisible(false)
|
||||
setIsAssetPanelEnabled(newValue)
|
||||
}}
|
||||
doEmptyTrash={doEmptyTrash}
|
||||
doCreateProject={doCreateProject}
|
||||
doUploadFiles={doUploadFiles}
|
||||
@ -292,31 +264,13 @@ export default function Drive(props: DriveProps) {
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
category={category}
|
||||
setSuggestions={setSuggestions}
|
||||
initialProjectName={initialProjectName}
|
||||
setAssetPanelProps={setAssetPanelProps}
|
||||
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
|
||||
targetDirectoryNodeRef={targetDirectoryNodeRef}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex flex-col overflow-hidden transition-min-width duration-side-panel ease-in-out',
|
||||
isAssetPanelVisible ? 'min-w-side-panel' : 'min-w',
|
||||
)}
|
||||
>
|
||||
<AssetPanel
|
||||
isVisible={isAssetPanelVisible}
|
||||
key={assetPanelProps?.item?.item.id}
|
||||
backend={assetPanelProps?.backend ?? null}
|
||||
item={assetPanelProps?.item ?? null}
|
||||
setItem={assetPanelProps?.setItem ?? null}
|
||||
category={category}
|
||||
isReadonly={category.type === 'trash'}
|
||||
/>
|
||||
</div>
|
||||
<AssetPanel backendType={backend.type} category={category} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -16,35 +16,25 @@ import { Button, ButtonGroup, DialogTrigger, useVisualTooltip } from '#/componen
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import { useOffline } from '#/hooks/offlineHooks'
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import AssetSearchBar, { type Suggestion } from '#/layouts/AssetSearchBar'
|
||||
import AssetSearchBar from '#/layouts/AssetSearchBar'
|
||||
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
import {
|
||||
isCloudCategory,
|
||||
isLocalCategory,
|
||||
type Category,
|
||||
} from '#/layouts/CategorySwitcher/Category'
|
||||
import { isCloudCategory, type Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import StartModal from '#/layouts/StartModal'
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
|
||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import { useCanDownload, useTargetDirectory } from '#/providers/DriveProvider'
|
||||
import {
|
||||
useCanCreateAssets,
|
||||
useCanDownload,
|
||||
useIsAssetPanelVisible,
|
||||
useSetIsAssetPanelPermanentlyVisible,
|
||||
} from '#/providers/DriveProvider'
|
||||
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||
import { useSetModal } from '#/providers/ModalProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
import {
|
||||
Plan,
|
||||
ProjectState,
|
||||
type CreatedProject,
|
||||
type Project,
|
||||
type ProjectId,
|
||||
} from '#/services/Backend'
|
||||
import { ProjectState, type CreatedProject, type Project, type ProjectId } from '#/services/Backend'
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import {
|
||||
canPermissionModifyDirectoryContents,
|
||||
tryFindSelfPermission,
|
||||
} from '#/utilities/permissions'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
|
||||
// ================
|
||||
@ -56,10 +46,7 @@ export interface DriveBarProps {
|
||||
readonly backend: Backend
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly suggestions: readonly Suggestion[]
|
||||
readonly category: Category
|
||||
readonly isAssetPanelOpen: boolean
|
||||
readonly setIsAssetPanelOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
readonly doEmptyTrash: () => void
|
||||
readonly doCreateProject: (
|
||||
templateId?: string | null,
|
||||
@ -76,29 +63,21 @@ export interface DriveBarProps {
|
||||
/** Displays the current directory path and permissions, upload and download buttons,
|
||||
* and a column display mode switcher. */
|
||||
export default function DriveBar(props: DriveBarProps) {
|
||||
const { backend, query, setQuery, suggestions, category } = props
|
||||
const { backend, query, setQuery, category } = props
|
||||
const { doEmptyTrash, doCreateProject, doCreateDirectory } = props
|
||||
const { doCreateSecret, doCreateDatalink, doUploadFiles } = props
|
||||
const { isAssetPanelOpen, setIsAssetPanelOpen } = props
|
||||
const { unsetModal } = useSetModal()
|
||||
const { getText } = useText()
|
||||
const { user } = useFullUserSession()
|
||||
const inputBindings = useInputBindings()
|
||||
const dispatchAssetEvent = useDispatchAssetEvent()
|
||||
const targetDirectory = useTargetDirectory()
|
||||
const canCreateAssets = useCanCreateAssets()
|
||||
const isAssetPanelVisible = useIsAssetPanelVisible()
|
||||
const setIsAssetPanelPermanentlyVisible = useSetIsAssetPanelPermanentlyVisible()
|
||||
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
|
||||
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
|
||||
const isCloud = isCloudCategory(category)
|
||||
const { isOffline } = useOffline()
|
||||
const canDownload = useCanDownload()
|
||||
const targetDirectorySelfPermission =
|
||||
targetDirectory == null ? null : tryFindSelfPermission(user, targetDirectory.item.permissions)
|
||||
const canCreateAssets =
|
||||
targetDirectory == null ?
|
||||
category.type !== 'cloud' || user.plan == null || user.plan === Plan.solo
|
||||
: isLocalCategory(category) ||
|
||||
(targetDirectorySelfPermission != null &&
|
||||
canPermissionModifyDirectoryContents(targetDirectorySelfPermission.permission))
|
||||
const shouldBeDisabled = (isCloud && isOffline) || !canCreateAssets
|
||||
const error =
|
||||
!shouldBeDisabled ? null
|
||||
@ -163,28 +142,22 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
}, [isFetching])
|
||||
|
||||
const searchBar = (
|
||||
<AssetSearchBar
|
||||
backend={backend}
|
||||
isCloud={isCloud}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
<AssetSearchBar backend={backend} isCloud={isCloud} query={query} setQuery={setQuery} />
|
||||
)
|
||||
|
||||
const assetPanelToggle = (
|
||||
<>
|
||||
{/* Spacing. */}
|
||||
<div className={!isAssetPanelOpen ? 'w-5' : 'hidden'} />
|
||||
<div className={!isAssetPanelVisible ? 'w-5' : 'hidden'} />
|
||||
<div className="absolute right-[15px] top-[27px] z-1">
|
||||
<Button
|
||||
size="medium"
|
||||
variant="custom"
|
||||
isActive={isAssetPanelOpen}
|
||||
isActive={isAssetPanelVisible}
|
||||
icon={RightPanelIcon}
|
||||
aria-label={isAssetPanelOpen ? getText('openAssetPanel') : getText('closeAssetPanel')}
|
||||
aria-label={isAssetPanelVisible ? getText('openAssetPanel') : getText('closeAssetPanel')}
|
||||
onPress={() => {
|
||||
setIsAssetPanelOpen((isOpen) => !isOpen)
|
||||
setIsAssetPanelPermanentlyVisible(!isAssetPanelVisible)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -4,8 +4,11 @@ import * as React from 'react'
|
||||
import invariant from 'tiny-invariant'
|
||||
import * as zustand from 'zustand'
|
||||
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import type { AssetId, DirectoryAsset } from 'enso-common/src/services/Backend'
|
||||
import type { AssetPanelContextProps } from '#/layouts/AssetPanel'
|
||||
import type { Suggestion } from '#/layouts/AssetSearchBar'
|
||||
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||
import type { AssetId } from 'enso-common/src/services/Backend'
|
||||
import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array'
|
||||
|
||||
// ==================
|
||||
// === DriveStore ===
|
||||
@ -13,14 +16,22 @@ import type { AssetId, DirectoryAsset } from 'enso-common/src/services/Backend'
|
||||
|
||||
/** The state of this zustand store. */
|
||||
interface DriveStore {
|
||||
readonly targetDirectory: AssetTreeNode<DirectoryAsset> | null
|
||||
readonly setTargetDirectory: (targetDirectory: AssetTreeNode<DirectoryAsset> | null) => void
|
||||
readonly canCreateAssets: boolean
|
||||
readonly setCanCreateAssets: (canCreateAssets: boolean) => void
|
||||
readonly canDownload: boolean
|
||||
readonly setCanDownload: (canDownload: boolean) => void
|
||||
readonly selectedKeys: ReadonlySet<AssetId>
|
||||
readonly setSelectedKeys: (selectedKeys: ReadonlySet<AssetId>) => void
|
||||
readonly visuallySelectedKeys: ReadonlySet<AssetId> | null
|
||||
readonly setVisuallySelectedKeys: (visuallySelectedKeys: ReadonlySet<AssetId> | null) => void
|
||||
readonly isAssetPanelPermanentlyVisible: boolean
|
||||
readonly setIsAssetPanelPermanentlyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
|
||||
readonly isAssetPanelTemporarilyVisible: boolean
|
||||
readonly setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
|
||||
readonly assetPanelProps: AssetPanelContextProps | null
|
||||
readonly setAssetPanelProps: (assetPanelProps: AssetPanelContextProps | null) => void
|
||||
readonly suggestions: readonly Suggestion[]
|
||||
readonly setSuggestions: (suggestions: readonly Suggestion[]) => void
|
||||
}
|
||||
|
||||
// =======================
|
||||
@ -43,34 +54,73 @@ export interface ProjectsProviderProps extends Readonly<React.PropsWithChildren>
|
||||
* containing the current element is focused. */
|
||||
export default function DriveProvider(props: ProjectsProviderProps) {
|
||||
const { children } = props
|
||||
const [store] = React.useState(() => {
|
||||
return zustand.createStore<DriveStore>((set) => ({
|
||||
targetDirectory: null,
|
||||
setTargetDirectory: (targetDirectory) => {
|
||||
set({ targetDirectory })
|
||||
const { localStorage } = useLocalStorage()
|
||||
const [store] = React.useState(() =>
|
||||
zustand.createStore<DriveStore>((set, get) => ({
|
||||
canCreateAssets: true,
|
||||
setCanCreateAssets: (canCreateAssets) => {
|
||||
if (get().canCreateAssets !== canCreateAssets) {
|
||||
set({ canCreateAssets })
|
||||
}
|
||||
},
|
||||
canDownload: false,
|
||||
setCanDownload: (canDownload) => {
|
||||
set({ canDownload })
|
||||
if (get().canDownload !== canDownload) {
|
||||
set({ canDownload })
|
||||
}
|
||||
},
|
||||
selectedKeys: new Set(),
|
||||
setSelectedKeys: (selectedKeys) => {
|
||||
set({ selectedKeys })
|
||||
if (
|
||||
get().selectedKeys !== selectedKeys &&
|
||||
(selectedKeys.size !== 0 || get().selectedKeys.size !== 0)
|
||||
) {
|
||||
set({ selectedKeys })
|
||||
}
|
||||
},
|
||||
visuallySelectedKeys: null,
|
||||
setVisuallySelectedKeys: (visuallySelectedKeys) => {
|
||||
set({ visuallySelectedKeys })
|
||||
if (
|
||||
get().visuallySelectedKeys !== visuallySelectedKeys &&
|
||||
(visuallySelectedKeys?.size !== 0 || get().visuallySelectedKeys?.size !== 0)
|
||||
) {
|
||||
set({ visuallySelectedKeys })
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
isAssetPanelPermanentlyVisible: localStorage.get('isAssetPanelVisible') ?? false,
|
||||
setIsAssetPanelPermanentlyVisible: (isAssetPanelPermanentlyVisible) => {
|
||||
if (get().isAssetPanelPermanentlyVisible !== isAssetPanelPermanentlyVisible) {
|
||||
set({ isAssetPanelPermanentlyVisible })
|
||||
localStorage.set('isAssetPanelVisible', isAssetPanelPermanentlyVisible)
|
||||
}
|
||||
},
|
||||
isAssetPanelTemporarilyVisible: false,
|
||||
setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible) => {
|
||||
if (get().isAssetPanelTemporarilyVisible !== isAssetPanelTemporarilyVisible) {
|
||||
set({ isAssetPanelTemporarilyVisible })
|
||||
}
|
||||
},
|
||||
assetPanelProps: null,
|
||||
setAssetPanelProps: (assetPanelProps) => {
|
||||
if (get().assetPanelProps !== assetPanelProps) {
|
||||
set({ assetPanelProps })
|
||||
}
|
||||
},
|
||||
suggestions: EMPTY_ARRAY,
|
||||
setSuggestions: (suggestions) => {
|
||||
if (
|
||||
get().suggestions !== suggestions &&
|
||||
(suggestions.length !== 0 || get().suggestions.length !== 0)
|
||||
) {
|
||||
set({ suggestions })
|
||||
}
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
return <DriveContext.Provider value={store}>{children}</DriveContext.Provider>
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === useDriveStore ===
|
||||
// =====================
|
||||
|
||||
/** The drive store. */
|
||||
export function useDriveStore() {
|
||||
const store = React.useContext(DriveContext)
|
||||
@ -80,82 +130,105 @@ export function useDriveStore() {
|
||||
return store
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === useTargetDirectory ===
|
||||
// ==========================
|
||||
|
||||
/** A function to get the target directory of the Asset Table selection. */
|
||||
export function useTargetDirectory() {
|
||||
/** Whether assets can be created in the current directory. */
|
||||
export function useCanCreateAssets() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.targetDirectory)
|
||||
return zustand.useStore(store, (state) => state.canCreateAssets)
|
||||
}
|
||||
|
||||
// =============================
|
||||
// === useSetTargetDirectory ===
|
||||
// =============================
|
||||
|
||||
/** A function to set the target directory of the Asset Table selection. */
|
||||
export function useSetTargetDirectory() {
|
||||
/** A function to set whether assets can be created in the current directory. */
|
||||
export function useSetCanCreateAssets() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setTargetDirectory)
|
||||
return zustand.useStore(store, (state) => state.setCanCreateAssets)
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === useCanDownload ===
|
||||
// ======================
|
||||
|
||||
/** Whether the current Asset Table selection is downloadble. */
|
||||
export function useCanDownload() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.canDownload)
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === useSetCanDownload ===
|
||||
// =========================
|
||||
|
||||
/** A function to set whether the current Asset Table selection is downloadble. */
|
||||
export function useSetCanDownload() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setCanDownload)
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === useSelectedKeys ===
|
||||
// =======================
|
||||
|
||||
/** The selected keys in the Asset Table. */
|
||||
export function useSelectedKeys() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.selectedKeys)
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === useSetSelectedKeys ===
|
||||
// ==========================
|
||||
|
||||
/** A function to set the selected keys of the Asset Table selection. */
|
||||
export function useSetSelectedKeys() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setSelectedKeys)
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// === useVisuallySelectedKeys ===
|
||||
// ===============================
|
||||
|
||||
/** The visually selected keys in the Asset Table. */
|
||||
export function useVisuallySelectedKeys() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.selectedKeys)
|
||||
}
|
||||
|
||||
// ==================================
|
||||
// === useSetVisuallySelectedKeys ===
|
||||
// ==================================
|
||||
|
||||
/** A function to set the visually selected keys in the Asset Table. */
|
||||
export function useSetVisuallySelectedKeys() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setVisuallySelectedKeys)
|
||||
}
|
||||
|
||||
/** Whether the Asset Panel is toggled on. */
|
||||
export function useIsAssetPanelPermanentlyVisible() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.isAssetPanelPermanentlyVisible)
|
||||
}
|
||||
|
||||
/** A function to set whether the Asset Panel is toggled on. */
|
||||
export function useSetIsAssetPanelPermanentlyVisible() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setIsAssetPanelPermanentlyVisible)
|
||||
}
|
||||
|
||||
/** Whether the Asset Panel is currently visible (e.g. for editing a Datalink). */
|
||||
export function useIsAssetPanelTemporarilyVisible() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.isAssetPanelTemporarilyVisible)
|
||||
}
|
||||
|
||||
/** A function to set whether the Asset Panel is currently visible (e.g. for editing a Datalink). */
|
||||
export function useSetIsAssetPanelTemporarilyVisible() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setIsAssetPanelTemporarilyVisible)
|
||||
}
|
||||
|
||||
/** Whether the Asset Panel is currently visible, either temporarily or permanently. */
|
||||
export function useIsAssetPanelVisible() {
|
||||
const isAssetPanelPermanentlyVisible = useIsAssetPanelPermanentlyVisible()
|
||||
const isAssetPanelTemporarilyVisible = useIsAssetPanelTemporarilyVisible()
|
||||
return isAssetPanelPermanentlyVisible || isAssetPanelTemporarilyVisible
|
||||
}
|
||||
|
||||
/** Props for the Asset Panel. */
|
||||
export function useAssetPanelProps() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.assetPanelProps)
|
||||
}
|
||||
|
||||
/** A function to set props for the Asset Panel. */
|
||||
export function useSetAssetPanelProps() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setAssetPanelProps)
|
||||
}
|
||||
|
||||
/** Search suggestions. */
|
||||
export function useSuggestions() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.suggestions)
|
||||
}
|
||||
|
||||
/** Set search suggestions. */
|
||||
export function useSetSuggestions() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setSuggestions)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user