Fix React Compiler lints + improve performance (#11450)

This commit is contained in:
somebody1234 2024-11-21 23:49:30 +10:00 committed by GitHub
parent 80ae5823dd
commit 8c2e2af5f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
102 changed files with 2889 additions and 2144 deletions

View File

@ -56,7 +56,7 @@ declare module '@tanstack/query-core' {
/** Query Client type suitable for shared use in React and Vue. */
export type QueryClient = vueQuery.QueryClient
const DEFAULT_QUERY_STALE_TIME_MS = 2 * 60 * 1000
const DEFAULT_QUERY_STALE_TIME_MS = Infinity
const DEFAULT_QUERY_PERSIST_TIME_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
const DEFAULT_BUSTER = 'v1.1'

View File

@ -1,6 +1,5 @@
/** @file Various actions, locators, and constants used in end-to-end tests. */
import * as test from '@playwright/test'
import * as path from 'path'
import { TEXTS } from 'enso-common/src/text'

View File

@ -1,14 +1,7 @@
/** @file Test the login flow. */
import * as test from '@playwright/test'
import {
INVALID_PASSWORD,
mockAll,
passAgreementsDialog,
TEXT,
VALID_EMAIL,
VALID_PASSWORD,
} from './actions'
import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions'
// =============
// === Tests ===

View File

@ -21,8 +21,7 @@
"build": "vite build",
"build-cloud": "cross-env CLOUD_BUILD=true corepack pnpm run build",
"preview": "vite preview",
"//": "max-warnings set to 41 to match the amount of warnings introduced by the new react compiler. Eventual goal is to remove all the warnings.",
"lint": "eslint . --cache --max-warnings=39",
"lint": "eslint . --max-warnings=0",
"format": "prettier --version && prettier --write src/ && eslint . --fix",
"dev:vite": "vite",
"test": "corepack pnpm run /^^^^test:.*/",

View File

@ -227,6 +227,27 @@ export default function App(props: AppProps) {
},
})
const queryClient = props.queryClient
// Force all queries to be stale
// We don't use the `staleTime` option because it's not performant
// and triggers unnecessary setTimeouts.
reactQuery.useQuery({
queryKey: ['refresh'],
queryFn: () => {
queryClient
.getQueryCache()
.getAll()
.forEach((query) => {
query.isStale = () => true
})
return null
},
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
refetchInterval: 2 * 60 * 1000,
})
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
// will redirect the user between the login/register pages and the dashboard.
@ -279,9 +300,11 @@ function AppRouter(props: AppRouterProps) {
const httpClient = useHttpClient()
const logger = useLogger()
const navigate = router.useNavigate()
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
const { setModal } = modalProvider.useSetModal()
const navigator2D = navigator2DProvider.useNavigator2D()
const localBackend = React.useMemo(

View File

@ -3,7 +3,7 @@
*
* `<AnimatedBackground />` component visually highlights selected items by sliding a background into view when hovered over or clicked.
*/
import type { Transition } from 'framer-motion'
import type { Transition, Variants } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import type { PropsWithChildren } from 'react'
import { createContext, memo, useContext, useId, useMemo } from 'react'
@ -34,9 +34,9 @@ const DEFAULT_TRANSITION: Transition = {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 20,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
mass: 0.1,
mass: 0.5,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
velocity: 12,
velocity: 8,
}
/** `<AnimatedBackground />` component visually highlights selected items by sliding a background into view when hovered over or clicked. */
@ -92,9 +92,16 @@ AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBa
animationClassName,
children,
isSelected,
underlayElement = <div className={twJoin('h-full w-full', animationClassName)} />,
underlayElement: rawUnderlayElement,
} = props
const defaultUnderlayElement = useMemo(
() => <div className={twJoin('h-full w-full', animationClassName)} />,
[animationClassName],
)
const underlayElement = rawUnderlayElement ?? defaultUnderlayElement
const context = useContext(AnimatedBackgroundContext)
invariant(context, '<AnimatedBackground.Item /> must be placed within an <AnimatedBackground />')
const { value: activeValue, transition, layoutId } = context
@ -107,7 +114,7 @@ AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBa
const isActive = isSelected ?? activeValue === value
return (
<div className={twJoin('relative *:isolate', className)}>
<div className={twJoin('relative', className)}>
<AnimatedBackgroundItemUnderlay
isActive={isActive}
underlayElement={underlayElement}
@ -115,7 +122,7 @@ AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBa
transition={transition}
/>
{children}
<div className="isolate contents">{children}</div>
</div>
)
})
@ -130,6 +137,11 @@ interface AnimatedBackgroundItemUnderlayProps {
readonly transition: Transition
}
const VARIANTS: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
}
/**
* Underlay for {@link AnimatedBackground.Item}.
*/
@ -145,11 +157,12 @@ const AnimatedBackgroundItemUnderlay = memo(function AnimatedBackgroundItemUnder
<motion.div
layout="position"
layoutId={`background-${layoutId}`}
className="pointer-events-none absolute inset-0"
className="pointer-events-none absolute inset-0 isolate"
transition={transition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
variants={VARIANTS}
initial="hidden"
animate="visible"
exit="hidden"
>
{underlayElement}
</motion.div>

View File

@ -1,17 +1,25 @@
/** @file A styled button. */
import * as React from 'react'
import {
memo,
useLayoutEffect,
useRef,
useState,
type ForwardedRef,
type ReactElement,
type ReactNode,
} from 'react'
import * as focusHooks from '#/hooks/focusHooks'
import { useFocusChild } from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import SvgMask from '#/components/SvgMask'
import { TEXT_STYLE, useVisualTooltip } from '#/components/AriaComponents/Text'
import { Tooltip, TooltipTrigger } from '#/components/AriaComponents/Tooltip'
import { forwardRef } from '#/utilities/react'
import type { ExtractFunction, VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
import { TEXT_STYLE, useVisualTooltip } from '../Text'
import { Tooltip, TooltipTrigger } from '../Tooltip'
// ==============
// === Button ===
@ -36,14 +44,10 @@ interface PropsWithoutHref {
export interface BaseButtonProps<Render>
extends Omit<VariantProps<typeof BUTTON_STYLES>, 'iconOnly'> {
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
readonly tooltip?: React.ReactElement | string | false | null
readonly tooltip?: ReactElement | string | false | null
readonly tooltipPlacement?: aria.Placement
/** The icon to display in the button */
readonly icon?:
| React.ReactElement
| string
| ((render: Render) => React.ReactElement | string | null)
| null
readonly icon?: ReactElement | string | ((render: Render) => ReactElement | string | null) | null
/** When `true`, icon will be shown only when hovered. */
readonly showIconOnHover?: boolean
/**
@ -267,6 +271,7 @@ export const BUTTON_STYLES = tv({
{ size: 'medium', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4 h-4' } },
{ size: 'large', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4.5 h-4.5' } },
{ size: 'hero', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-12 h-12' } },
{ fullWidth: false, class: { icon: 'flex-none' } },
{ variant: 'link', isFocused: true, class: 'focus-visible:outline-offset-1' },
{ variant: 'link', size: 'xxsmall', class: 'font-medium' },
@ -279,217 +284,214 @@ export const BUTTON_STYLES = tv({
})
/** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */
export const Button = forwardRef(function Button(
props: ButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>,
) {
const {
className,
contentClassName,
children,
variant,
icon,
loading = false,
isActive,
showIconOnHover,
iconPosition,
size,
fullWidth,
rounded,
tooltip,
tooltipPlacement,
testId,
loaderPosition = 'full',
extraClickZone: extraClickZoneProp,
onPress = () => {},
variants = BUTTON_STYLES,
...ariaProps
} = props
const focusChildProps = focusHooks.useFocusChild()
export const Button = memo(
forwardRef(function Button(props: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
const {
className,
contentClassName,
children,
variant,
icon,
loading = false,
isActive,
showIconOnHover,
iconPosition,
size,
fullWidth,
rounded,
tooltip,
tooltipPlacement,
testId,
loaderPosition = 'full',
extraClickZone: extraClickZoneProp,
onPress = () => {},
variants = BUTTON_STYLES,
...ariaProps
} = props
const focusChildProps = useFocusChild()
const [implicitlyLoading, setImplicitlyLoading] = React.useState(false)
const contentRef = React.useRef<HTMLSpanElement>(null)
const loaderRef = React.useRef<HTMLSpanElement>(null)
const [implicitlyLoading, setImplicitlyLoading] = useState(false)
const contentRef = useRef<HTMLSpanElement>(null)
const loaderRef = useRef<HTMLSpanElement>(null)
const isLink = ariaProps.href != null
const isLink = ariaProps.href != null
const Tag = isLink ? aria.Link : aria.Button
const Tag = isLink ? aria.Link : aria.Button
const goodDefaults = {
...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }),
'data-testid': testId,
}
const isIconOnly = (children == null || children === '' || children === false) && icon != null
const shouldShowTooltip = (() => {
if (tooltip === false) {
return false
} else if (isIconOnly) {
return true
} else {
return tooltip != null
const goodDefaults = {
...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }),
'data-testid': testId,
}
})()
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
const isLoading = loading || implicitlyLoading
const isDisabled = props.isDisabled ?? isLoading
const shouldUseVisualTooltip = shouldShowTooltip && isDisabled
React.useLayoutEffect(() => {
const delay = 350
if (isLoading) {
const loaderAnimation = loaderRef.current?.animate(
[{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }],
{ duration: delay, easing: 'linear', delay: 0, fill: 'forwards' },
)
const contentAnimation =
loaderPosition !== 'full' ? null : (
contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 0,
easing: 'linear',
delay,
fill: 'forwards',
})
)
return () => {
loaderAnimation?.cancel()
contentAnimation?.cancel()
}
} else {
return () => {}
}
}, [isLoading, loaderPosition])
const handlePress = (event: aria.PressEvent): void => {
if (!isDisabled) {
const result = onPress?.(event)
if (result instanceof Promise) {
setImplicitlyLoading(true)
void result.finally(() => {
setImplicitlyLoading(false)
})
}
}
}
const styles = variants({
isDisabled,
isActive,
loading: isLoading,
fullWidth,
size,
rounded,
variant,
iconPosition,
showIconOnHover,
extraClickZone: extraClickZoneProp,
iconOnly: isIconOnly,
})
const childrenFactory = (
render: aria.ButtonRenderProps | aria.LinkRenderProps,
): React.ReactNode => {
const iconComponent = (() => {
if (isLoading && loaderPosition === 'icon') {
return (
<span className={styles.icon()}>
<StatelessSpinner state="loading-medium" size={16} />
</span>
)
} else if (icon == null) {
return null
const isIconOnly = (children == null || children === '' || children === false) && icon != null
const shouldShowTooltip = (() => {
if (tooltip === false) {
return false
} else if (isIconOnly) {
return true
} else {
/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */
const actualIcon = typeof icon === 'function' ? icon(render) : icon
if (typeof actualIcon === 'string') {
return <SvgMask src={actualIcon} className={styles.icon()} />
} else {
return <span className={styles.icon()}>{actualIcon}</span>
}
return tooltip != null
}
})()
// Icon only button
if (isIconOnly) {
return <span className={styles.extraClickZone()}>{iconComponent}</span>
} else {
// Default button
return (
<>
{iconComponent}
<span className={styles.text()}>
{/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */}
{typeof children === 'function' ? children(render) : children}
</span>
</>
)
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
const isLoading = loading || implicitlyLoading
const isDisabled = props.isDisabled ?? isLoading
const shouldUseVisualTooltip = shouldShowTooltip && isDisabled
useLayoutEffect(() => {
const delay = 350
if (isLoading) {
const loaderAnimation = loaderRef.current?.animate(
[{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }],
{ duration: delay, easing: 'linear', delay: 0, fill: 'forwards' },
)
const contentAnimation =
loaderPosition !== 'full' ? null : (
contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 0,
easing: 'linear',
delay,
fill: 'forwards',
})
)
return () => {
loaderAnimation?.cancel()
contentAnimation?.cancel()
}
} else {
return () => {}
}
}, [isLoading, loaderPosition])
const handlePress = (event: aria.PressEvent): void => {
if (!isDisabled) {
const result = onPress?.(event)
if (result instanceof Promise) {
setImplicitlyLoading(true)
void result.finally(() => {
setImplicitlyLoading(false)
})
}
}
}
}
const { tooltip: visualTooltip, targetProps } = useVisualTooltip({
targetRef: contentRef,
children: tooltipElement,
isDisabled: !shouldUseVisualTooltip,
...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }),
})
const styles = variants({
isDisabled,
isActive,
loading: isLoading,
fullWidth,
size,
rounded,
variant,
iconPosition,
showIconOnHover,
extraClickZone: extraClickZoneProp,
iconOnly: isIconOnly,
})
const button = (
<Tag
// @ts-expect-error ts errors are expected here because we are merging props with different types
ref={ref}
// @ts-expect-error ts errors are expected here because we are merging props with different types
{...aria.mergeProps<aria.ButtonProps>()(goodDefaults, ariaProps, focusChildProps, {
isDisabled,
// we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger
// onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered
onPressEnd: (e) => {
if (!isDisabled) {
handlePress(e)
}
},
className: aria.composeRenderProps(className, (classNames, states) =>
styles.base({ className: classNames, ...states }),
),
})}
>
{(render: aria.ButtonRenderProps | aria.LinkRenderProps) => (
<span className={styles.wrapper()}>
<span
ref={contentRef}
className={styles.content({ className: contentClassName })}
{...targetProps}
>
{}
{childrenFactory(render)}
</span>
{isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={styles.loader()}>
const childrenFactory = (render: aria.ButtonRenderProps | aria.LinkRenderProps): ReactNode => {
const iconComponent = (() => {
if (isLoading && loaderPosition === 'icon') {
return (
<span className={styles.icon()}>
<StatelessSpinner state="loading-medium" size={16} />
</span>
)}
</span>
)}
</Tag>
)
)
} else if (icon == null) {
return null
} else {
/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */
const actualIcon = typeof icon === 'function' ? icon(render) : icon
return (
tooltipElement == null ? button
: shouldUseVisualTooltip ?
<>
{button}
{visualTooltip}
</>
: <TooltipTrigger delay={0} closeDelay={0}>
{button}
if (typeof actualIcon === 'string') {
return <SvgMask src={actualIcon} className={styles.icon()} />
} else {
return <span className={styles.icon()}>{actualIcon}</span>
}
}
})()
// Icon only button
if (isIconOnly) {
return <span className={styles.extraClickZone()}>{iconComponent}</span>
} else {
// Default button
return (
<>
{iconComponent}
<span className={styles.text()}>
{/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */}
{typeof children === 'function' ? children(render) : children}
</span>
</>
)
}
}
<Tooltip {...(tooltipPlacement != null ? { placement: tooltipPlacement } : {})}>
{tooltipElement}
</Tooltip>
</TooltipTrigger>
)
})
const { tooltip: visualTooltip, targetProps } = useVisualTooltip({
targetRef: contentRef,
children: tooltipElement,
isDisabled: !shouldUseVisualTooltip,
...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }),
})
const button = (
<Tag
// @ts-expect-error ts errors are expected here because we are merging props with different types
ref={ref}
// @ts-expect-error ts errors are expected here because we are merging props with different types
{...aria.mergeProps<aria.ButtonProps>()(goodDefaults, ariaProps, focusChildProps, {
isDisabled,
// we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger
// onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered
onPressEnd: (e) => {
if (!isDisabled) {
handlePress(e)
}
},
className: aria.composeRenderProps(className, (classNames, states) =>
styles.base({ className: classNames, ...states }),
),
})}
>
{(render: aria.ButtonRenderProps | aria.LinkRenderProps) => (
<span className={styles.wrapper()}>
<span
ref={contentRef}
className={styles.content({ className: contentClassName })}
{...targetProps}
>
{}
{childrenFactory(render)}
</span>
{isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={styles.loader()}>
<StatelessSpinner state="loading-medium" size={16} />
</span>
)}
</span>
)}
</Tag>
)
return (
tooltipElement == null ? button
: shouldUseVisualTooltip ?
<>
{button}
{visualTooltip}
</>
: <TooltipTrigger delay={0} closeDelay={0}>
{button}
<Tooltip {...(tooltipPlacement != null ? { placement: tooltipPlacement } : {})}>
{tooltipElement}
</Tooltip>
</TooltipTrigger>
)
}),
)

View File

@ -4,6 +4,7 @@ import { Button, type ButtonProps } from '#/components/AriaComponents/Button'
import { useText } from '#/providers/TextProvider'
import { twMerge } from '#/utilities/tailwindMerge'
import { isOnMacOS } from 'enso-common/src/detect'
import { memo } from 'react'
// ===================
// === CloseButton ===
@ -13,7 +14,7 @@ import { isOnMacOS } from 'enso-common/src/detect'
export type CloseButtonProps = Omit<ButtonProps, 'children' | 'rounding' | 'size' | 'variant'>
/** A styled button with a close icon that appears on hover. */
export function CloseButton(props: CloseButtonProps) {
export const CloseButton = memo(function CloseButton(props: CloseButtonProps) {
const { getText } = useText()
const {
className,
@ -26,13 +27,14 @@ export function CloseButton(props: CloseButtonProps) {
return (
<Button
variant="icon"
// @ts-expect-error ts fails to infer the type of the className prop
className={(values) =>
twMerge(
'hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
isOnMacOS() ? 'bg-primary/30' : (
'text-primary/90 hover:text-primary focus-visible:text-primary'
),
// @ts-expect-error ts fails to infer the type of the className prop
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
typeof className === 'function' ? className(values) : className,
)
}
@ -48,4 +50,4 @@ export function CloseButton(props: CloseButtonProps) {
{...(buttonProps as any)}
/>
)
}
})

View File

@ -4,7 +4,8 @@ import * as React from 'react'
import invariant from 'tiny-invariant'
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
import type { StoreApi } from '#/utilities/zustand'
import { createStore, useStore } from '#/utilities/zustand'
/** DialogStackItem represents an item in the dialog stack. */
export interface DialogStackItem {
@ -20,72 +21,72 @@ export interface DialogStackContextType {
readonly slice: (currentId: string) => void
}
const DialogStackContext = React.createContext<DialogStackContextType | null>(null)
const DialogStackContext = React.createContext<StoreApi<DialogStackContextType> | null>(null)
/** DialogStackProvider is a React component that provides the dialog stack context to its children. */
export function DialogStackProvider(props: React.PropsWithChildren) {
const { children } = props
const [stack, setStack] = React.useState<DialogStackItem[]>([])
const [store] = React.useState(() =>
createStore<DialogStackContextType>((set) => ({
stack: [],
dialogsStack: [],
add: (item) => {
set((state) => {
const nextStack = [...state.stack, item]
const addToStack = eventCallbackHooks.useEventCallback((item: DialogStackItem) => {
setStack((currentStack) => [...currentStack, item])
})
return {
stack: nextStack,
dialogsStack: nextStack.filter((stackItem) =>
['dialog-fullscreen', 'dialog'].includes(stackItem.type),
),
}
})
},
slice: (currentId) => {
set((state) => {
const lastItem = state.stack.at(-1)
if (lastItem?.id === currentId) {
return { stack: state.stack.slice(0, -1) }
} else {
// eslint-disable-next-line no-restricted-properties
console.warn(`
DialogStackProvider: sliceFromStack: currentId ${currentId} does not match the last item in the stack. \
This is no-op but it might be a sign of a bug in the application. \
Usually, this means that the underlaying component was closed manually or the stack was not \
updated properly.
`)
const sliceFromStack = eventCallbackHooks.useEventCallback((currentId: string) => {
setStack((currentStack) => {
const lastItem = currentStack.at(-1)
if (lastItem?.id === currentId) {
return currentStack.slice(0, -1)
} else {
// eslint-disable-next-line no-restricted-properties
console.warn(`
DialogStackProvider: sliceFromStack: currentId ${currentId} does not match the last item in the stack. \
This is no-op but it might be a sign of a bug in the application. \
Usually, this means that the underlaying component was closed manually or the stack was not \
updated properly.`)
return currentStack
}
})
})
const value = React.useMemo<DialogStackContextType>(
() => ({
stack,
dialogsStack: stack.filter((item) => ['dialog-fullscreen', 'dialog'].includes(item.type)),
add: addToStack,
slice: sliceFromStack,
}),
[stack, addToStack, sliceFromStack],
return { stack: state.stack }
}
})
},
})),
)
return <DialogStackContext.Provider value={value}>{children}</DialogStackContext.Provider>
return <DialogStackContext.Provider value={store}>{children}</DialogStackContext.Provider>
}
/** DialogStackRegistrar is a React component that registers a dialog in the dialog stack. */
export function DialogStackRegistrar(props: React.PropsWithChildren<DialogStackItem>) {
const { children, id: idRaw, type: typeRaw } = props
const idRef = React.useRef(idRaw)
const typeRef = React.useRef(typeRaw)
const { children, id, type } = props
const context = React.useContext(DialogStackContext)
const store = React.useContext(DialogStackContext)
invariant(store, 'DialogStackRegistrar must be used within a DialogStackProvider')
invariant(context, 'DialogStackRegistrar must be used within a DialogStackProvider')
const { add, slice } = context
const { add, slice } = useStore(store, (state) => ({ add: state.add, slice: state.slice }))
React.useEffect(() => {
const id = idRef.current
const type = typeRef.current
add({ id, type })
React.startTransition(() => {
add({ id, type })
})
return () => {
slice(id)
React.startTransition(() => {
slice(id)
})
}
}, [add, slice])
}, [add, slice, id, type])
return children
}
@ -97,14 +98,33 @@ export interface UseDialogStackStateProps {
/** useDialogStackState is a custom hook that provides the state of the dialog stack. */
export function useDialogStackState(props: UseDialogStackStateProps) {
const ctx = React.useContext(DialogStackContext)
const store = React.useContext(DialogStackContext)
invariant(store, 'useDialogStackState must be used within a DialogStackProvider')
invariant(ctx, 'useDialogStackState must be used within a DialogStackProvider')
const { id } = props
const isLatest = ctx.stack.at(-1)?.id === id
const index = ctx.stack.findIndex((item) => item.id === id)
const isLatest = useIsLatestDialogStackItem(props.id)
const index = useDialogStackIndex(props.id)
return { isLatest, index }
}
/**
* Hook that returns true if the given id is the latest item in the dialog stack.
*/
export function useIsLatestDialogStackItem(id: string) {
const store = React.useContext(DialogStackContext)
invariant(store, 'useIsLatestDialogStackItem must be used within a DialogStackProvider')
return useStore(store, (state) => state.stack.at(-1)?.id === id, { unsafeEnableTransition: true })
}
/**
* Hook that returns the index of the given id in the dialog stack.
*/
export function useDialogStackIndex(id: string) {
const store = React.useContext(DialogStackContext)
invariant(store, 'useDialogStackIndex must be used within a DialogStackProvider')
return useStore(store, (state) => state.stack.findIndex((item) => item.id === id), {
unsafeEnableTransition: true,
})
}

View File

@ -64,6 +64,8 @@ export const POPOVER_STYLES = twv.tv({
defaultVariants: { rounded: 'xxlarge', size: 'small' },
})
const SUSPENSE_LOADER_PROPS = { minHeight: 'h32' } as const
/**
* A popover is an overlay element positioned relative to a trigger.
* It can be used to display additional content or actions.*
@ -98,6 +100,9 @@ export function Popover(props: PopoverProps) {
onInteractOutside: close,
})
const dialogContextValue = React.useMemo(() => ({ close, dialogId }), [close, dialogId])
const popoverStyle = React.useMemo(() => ({ zIndex: '' }), [])
return (
<aria.Popover
className={(values) =>
@ -111,10 +116,7 @@ export function Popover(props: PopoverProps) {
}
UNSTABLE_portalContainer={root}
placement={placement}
style={{
// Unset the default z-index set by react-aria-components.
zIndex: '',
}}
style={popoverStyle}
shouldCloseOnInteractOutside={() => false}
{...ariaPopoverProps}
>
@ -125,9 +127,9 @@ export function Popover(props: PopoverProps) {
ref={dialogRef}
className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()}
>
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<dialogProvider.DialogProvider value={dialogContextValue}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={{ minHeight: 'h32' }}>
<suspense.Suspense loaderProps={SUSPENSE_LOADER_PROPS}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>

View File

@ -41,7 +41,8 @@ export function useInteractOutside(props: UseInteractOutsideProps) {
const { ref, id, onInteractOutside, isDisabled = false } = props
const shouldCloseOnInteractOutsideRef = React.useRef(false)
const { isLatest } = dialogStackProvider.useDialogStackState({ id })
const isLatest = dialogStackProvider.useIsLatestDialogStackItem(id)
const onInteractOutsideStartCb = eventCallback.useEventCallback((e: MouseEvent) => {
// eslint-disable-next-line no-restricted-syntax
shouldCloseOnInteractOutsideRef.current = !shouldIgnoreInteractOutside(e.target as HTMLElement)

View File

@ -64,7 +64,7 @@ export interface OnSubmitCallbacks<Schema extends TSchema, SubmitResult = void>
}
/** Props for the useForm hook. */
export interface UseFormProps<Schema extends TSchema, SubmitResult = void>
export interface UseFormOptions<Schema extends TSchema, SubmitResult = void>
extends Omit<
reactHookForm.UseFormProps<FieldValues<Schema>>,
'handleSubmit' | 'resetOptions' | 'resolver'

View File

@ -43,7 +43,7 @@ function mapValueOnEvent(value: unknown) {
* Otherwise you'll be fired
*/
export function useForm<Schema extends types.TSchema, SubmitResult = void>(
optionsOrFormInstance: types.UseFormProps<Schema, SubmitResult> | types.UseFormReturn<Schema>,
optionsOrFormInstance: types.UseFormOptions<Schema, SubmitResult> | types.UseFormReturn<Schema>,
): types.UseFormReturn<Schema> {
const { getText } = useText()
const [initialTypePassed] = React.useState(() => getArgsType(optionsOrFormInstance))
@ -242,7 +242,7 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
/** Get the type of arguments passed to the useForm hook */
function getArgsType<Schema extends types.TSchema, SubmitResult = void>(
args: types.UseFormProps<Schema, SubmitResult>,
args: types.UseFormOptions<Schema, SubmitResult>,
) {
return 'formState' in args ? ('formInstance' as const) : ('formOptions' as const)
}

View File

@ -71,7 +71,7 @@ interface FormPropsWithOptions<Schema extends components.TSchema, SubmitResult =
extends components.OnSubmitCallbacks<Schema, SubmitResult> {
readonly schema: Schema | ((schema: typeof components.schema) => Schema)
readonly formOptions?: Omit<
components.UseFormProps<Schema, SubmitResult>,
components.UseFormOptions<Schema, SubmitResult>,
'defaultValues' | 'onSubmit' | 'onSubmitFailed' | 'onSubmitSuccess' | 'onSubmitted' | 'schema'
>
/**
@ -81,7 +81,7 @@ interface FormPropsWithOptions<Schema extends components.TSchema, SubmitResult =
* it is recommended to provide default values and specify all fields defined in the schema.
* Otherwise Typescript fails to infer the correct type for the form values.
*/
readonly defaultValues?: components.UseFormProps<Schema>['defaultValues']
readonly defaultValues?: components.UseFormOptions<Schema>['defaultValues']
readonly form?: never
}

View File

@ -139,7 +139,9 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
<div className={styles.inputContainer()}>
<div
className={styles.textArea()}
ref={mergeRefs(inputRef, ref, field.ref)}
ref={(el) => {
mergeRefs(inputRef, ref, field.ref)(el)
}}
contentEditable
suppressContentEditableWarning
role="textbox"

View File

@ -60,7 +60,9 @@ export const ResizableInput = forwardRef(function ResizableInput(
>
<div className={inputContainer()}>
<aria.TextArea
ref={mergeRefs.mergeRefs(inputRef, ref)}
ref={(el) => {
mergeRefs.mergeRefs(inputRef, ref)(el)
}}
onPaste={onPaste}
className={textArea()}
placeholder={placeholder}

View File

@ -5,6 +5,7 @@ import { forwardRef } from '#/utilities/react'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
import * as React from 'react'
import { memo } from 'react'
import { TEXT_STYLE } from '../../Text'
/** Props for a {@link SelectorOption}. */
@ -95,41 +96,43 @@ export const SELECTOR_OPTION_STYLES = tv({
},
})
export const SelectorOption = forwardRef(function SelectorOption(
props: SelectorOptionProps,
ref: React.ForwardedRef<HTMLLabelElement>,
) {
const {
label,
value,
size,
rounded,
variant,
className,
variants = SELECTOR_OPTION_STYLES,
...radioProps
} = props
export const SelectorOption = memo(
forwardRef(function SelectorOption(
props: SelectorOptionProps,
ref: React.ForwardedRef<HTMLLabelElement>,
) {
const {
label,
value,
size,
rounded,
variant,
className,
variants = SELECTOR_OPTION_STYLES,
...radioProps
} = props
const styles = variants({ size, rounded, variant })
const styles = variants({ size, rounded, variant })
return (
<AnimatedBackground.Item
value={value}
className={styles.base()}
animationClassName={styles.animation()}
>
<Radio
ref={ref}
{...radioProps}
return (
<AnimatedBackground.Item
value={value}
className={(renderProps) =>
styles.radio({
className: typeof className === 'function' ? className(renderProps) : className,
})
}
className={styles.base()}
animationClassName={styles.animation()}
>
{label}
</Radio>
</AnimatedBackground.Item>
)
})
<Radio
ref={ref}
{...radioProps}
value={value}
className={(renderProps) =>
styles.radio({
className: typeof className === 'function' ? className(renderProps) : className,
})
}
>
{label}
</Radio>
</AnimatedBackground.Item>
)
}),
)

View File

@ -6,7 +6,9 @@ import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { forwardRef } from '#/utilities/react'
import { memo } from 'react'
import * as textProvider from './TextProvider'
import * as visualTooltip from './useVisualTooltip'
@ -120,107 +122,107 @@ export const TEXT_STYLE = twv.tv({
/** Text component that supports truncation and show a tooltip on hover when text is truncated */
// eslint-disable-next-line no-restricted-syntax
export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref<HTMLSpanElement>) {
const {
className,
variant,
font,
italic,
weight,
nowrap,
monospace,
transform,
truncate,
lineClamp = 1,
children,
color,
balance,
elementType: ElementType = 'span',
tooltip: tooltipElement = children,
tooltipTriggerRef,
tooltipDisplay = 'whenOverflowing',
tooltipPlacement,
tooltipOffset,
tooltipCrossOffset,
textSelection,
disableLineHeightCompensation = false,
...ariaProps
} = props
export const Text = memo(
forwardRef(function Text(props: TextProps, ref: React.Ref<HTMLSpanElement>) {
const {
className,
variant,
font,
italic,
weight,
nowrap,
monospace,
transform,
truncate,
lineClamp = 1,
children,
color,
balance,
elementType: ElementType = 'span',
tooltip: tooltipElement = children,
tooltipDisplay = 'whenOverflowing',
tooltipPlacement,
tooltipOffset,
tooltipCrossOffset,
textSelection,
disableLineHeightCompensation = false,
...ariaProps
} = props
const textElementRef = React.useRef<HTMLElement>(null)
const textContext = textProvider.useTextContext()
const textElementRef = React.useRef<HTMLElement>(null)
const textContext = textProvider.useTextContext()
const textClasses = TEXT_STYLE({
variant,
font,
weight,
transform,
monospace,
italic,
nowrap,
truncate,
color,
balance,
textSelection,
disableLineHeightCompensation:
disableLineHeightCompensation === false ?
textContext.isInsideTextComponent
: disableLineHeightCompensation,
className,
})
const textClasses = TEXT_STYLE({
variant,
font,
weight,
transform,
monospace,
italic,
nowrap,
truncate,
color,
balance,
textSelection,
disableLineHeightCompensation:
disableLineHeightCompensation === false ?
textContext.isInsideTextComponent
: disableLineHeightCompensation,
className,
})
const isTooltipDisabled = () => {
if (tooltipDisplay === 'whenOverflowing') {
return !truncate
} else if (tooltipDisplay === 'always') {
return tooltipElement === false || tooltipElement == null
} else {
return false
}
}
const { tooltip, targetProps } = visualTooltip.useVisualTooltip({
isDisabled: isTooltipDisabled(),
targetRef: textElementRef,
triggerRef: tooltipTriggerRef,
display: tooltipDisplay,
children: tooltipElement,
...(tooltipPlacement || tooltipOffset != null ?
{
overlayPositionProps: {
...(tooltipPlacement && { placement: tooltipPlacement }),
...(tooltipOffset != null && { offset: tooltipOffset }),
...(tooltipCrossOffset != null && { crossOffset: tooltipCrossOffset }),
},
const isTooltipDisabled = useEventCallback(() => {
if (tooltipDisplay === 'whenOverflowing') {
return !truncate
} else if (tooltipDisplay === 'always') {
return tooltipElement === false || tooltipElement == null
} else {
return false
}
: {}),
})
})
return (
<textProvider.TextProvider value={{ isInsideTextComponent: true }}>
<ElementType
// @ts-expect-error This is caused by the type-safe `elementType` type.
ref={(el) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
mergeRefs.mergeRefs(ref, textElementRef)(el)
}}
className={textClasses}
{...aria.mergeProps<React.HTMLAttributes<HTMLElement>>()(
ariaProps,
targetProps,
truncate === 'custom' ?
// eslint-disable-next-line @typescript-eslint/naming-convention,no-restricted-syntax
({ style: { '--line-clamp': `${lineClamp}` } } as React.HTMLAttributes<HTMLElement>)
: {},
)}
>
{children}
</ElementType>
const { tooltip, targetProps } = visualTooltip.useVisualTooltip({
isDisabled: isTooltipDisabled(),
targetRef: textElementRef,
display: tooltipDisplay,
children: tooltipElement,
...(tooltipPlacement || tooltipOffset != null || tooltipCrossOffset != null ?
{
overlayPositionProps: {
...(tooltipPlacement && { placement: tooltipPlacement }),
...(tooltipOffset != null && { offset: tooltipOffset }),
...(tooltipCrossOffset != null && { crossOffset: tooltipCrossOffset }),
},
}
: {}),
})
{tooltip}
</textProvider.TextProvider>
)
}) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {
return (
<textProvider.TextProvider value={{ isInsideTextComponent: true }}>
<ElementType
// @ts-expect-error This is caused by the type-safe `elementType` type.
ref={(el) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
mergeRefs.mergeRefs(ref, textElementRef)(el)
}}
className={textClasses}
{...aria.mergeProps<React.HTMLAttributes<HTMLElement>>()(
ariaProps,
targetProps,
truncate === 'custom' ?
// eslint-disable-next-line @typescript-eslint/naming-convention,no-restricted-syntax
({ style: { '--line-clamp': `${lineClamp}` } } as React.HTMLAttributes<HTMLElement>)
: {},
)}
>
{children}
</ElementType>
{tooltip}
</textProvider.TextProvider>
)
}),
) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {
// eslint-disable-next-line @typescript-eslint/naming-convention
Heading: typeof Heading
// eslint-disable-next-line @typescript-eslint/naming-convention

View File

@ -69,19 +69,16 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
maxWidth,
} = props
const {
containerPadding = 0,
offset = DEFAULT_OFFSET,
crossOffset = 0,
placement = 'bottom',
} = overlayPositionProps
const [isTooltipDisabled, setIsTooltipDisabled] = React.useState(true)
const popoverRef = React.useRef<HTMLDivElement>(null)
const id = React.useId()
const disabled = isDisabled || isTooltipDisabled
const state = aria.useTooltipTriggerState({
closeDelay: DEFAULT_DELAY,
delay: DEFAULT_DELAY,
isDisabled: disabled,
})
const handleHoverChange = eventCallback.useEventCallback((isHovered: boolean) => {
@ -95,11 +92,15 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
}
}
if (shouldDisplay()) {
state.open()
} else {
state.close()
}
React.startTransition(() => {
setIsTooltipDisabled(!shouldDisplay())
if (shouldDisplay()) {
state.open()
} else {
state.close()
}
})
})
const { hoverProps: targetHoverProps } = aria.useHover({
@ -107,12 +108,82 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
onHoverChange: handleHoverChange,
})
return {
targetProps: aria.mergeProps<React.HTMLAttributes<HTMLElement>>()(targetHoverProps, { id }),
tooltip:
state.isOpen ?
<TooltipInner
id={id}
overlayPositionProps={overlayPositionProps}
className={className}
variant={variant}
rounded={rounded}
size={size}
maxWidth={maxWidth}
children={children}
testId={testId}
state={state}
targetRef={targetRef}
triggerRef={triggerRef}
disabled={disabled}
handleHoverChange={handleHoverChange}
/>
: null,
} as const
}
/** Props for {@link TooltipInner}. */
interface TooltipInnerProps
extends Pick<ariaComponents.TooltipProps, 'maxWidth' | 'rounded' | 'size' | 'variant'> {
readonly id: string
readonly disabled: boolean
readonly handleHoverChange: (isHovered: boolean) => void
readonly state: aria.TooltipTriggerState
readonly targetRef: React.RefObject<HTMLElement>
readonly triggerRef: React.RefObject<HTMLElement>
readonly children: React.ReactNode
readonly className?: string | undefined
readonly testId?: string | undefined
readonly overlayPositionProps: Pick<
aria.AriaPositionProps,
'containerPadding' | 'crossOffset' | 'offset' | 'placement'
>
}
/** The inner component of the tooltip. */
function TooltipInner(props: TooltipInnerProps) {
const {
id,
disabled,
handleHoverChange,
state,
targetRef,
triggerRef,
className,
variant,
rounded,
size,
maxWidth,
children,
testId,
overlayPositionProps,
} = props
const {
containerPadding = 0,
offset = DEFAULT_OFFSET,
crossOffset = 0,
placement = 'bottom',
} = overlayPositionProps
const popoverRef = React.useRef<HTMLDivElement>(null)
const { hoverProps: tooltipHoverProps } = aria.useHover({
isDisabled,
isDisabled: disabled,
onHoverChange: handleHoverChange,
})
const { tooltipProps } = aria.useTooltipTrigger({}, state, targetRef)
const { tooltipProps } = aria.useTooltipTrigger({ isDisabled: disabled }, state, targetRef)
// eslint-disable-next-line @typescript-eslint/unbound-method
const { overlayProps, updatePosition } = aria.useOverlayPosition({
@ -162,10 +233,7 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
</Portal>
)
return {
targetProps: aria.mergeProps<React.HTMLAttributes<HTMLElement>>()(targetHoverProps, { id }),
tooltip: state.isOpen ? createTooltipElement() : null,
} as const
return createTooltipElement()
}
const DISPLAY_STRATEGIES: Record<DisplayStrategy, (target: HTMLElement) => boolean> = {

View File

@ -47,7 +47,6 @@ import {
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import * as backend from '#/services/Backend'
import LocalStorage, { type LocalStorageData } from '#/utilities/LocalStorage'
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
/** A component that provides a UI for toggling paywall features. */
export function EnsoDevtools() {
@ -280,7 +279,7 @@ export function EnsoDevtools() {
variant="icon"
icon={TrashIcon}
onPress={() => {
for (const [key] of unsafeEntries(LocalStorage.keyMetadata)) {
for (const key of LocalStorage.getAllKeys()) {
localStorage.delete(key)
}
}}
@ -288,7 +287,7 @@ export function EnsoDevtools() {
</div>
<div className="flex flex-col gap-0.5">
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
{LocalStorage.getAllKeys().map((key) => (
<div key={key} className="flex w-full items-center justify-between gap-1">
<Text variant="body">
{key

View File

@ -2,7 +2,6 @@
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as reactQueryDevtools from '@tanstack/react-query-devtools'
import * as errorBoundary from 'react-error-boundary'
import { useShowDevtools } from './EnsoDevtoolsProvider'
@ -28,8 +27,6 @@ export function ReactQueryDevtools() {
return null
}}
>
<reactQueryDevtools.ReactQueryDevtools client={client} />
{showDevtools && (
<React.Suspense fallback={null}>
<ReactQueryDevtoolsProduction client={client} />

View File

@ -17,6 +17,7 @@ import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useAutoFocus } from '#/hooks/autoFocusHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
// =================
// === Constants ===
@ -53,8 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null)
const cancelledRef = React.useRef(false)
const checkSubmittableRef = React.useRef(checkSubmittable)
checkSubmittableRef.current = checkSubmittable
const checkSubmittableRef = useSyncRef(checkSubmittable)
// Make sure that the event callback is stable to prevent the effect from re-running.
const onCancelEventCallback = eventCallback.useEventCallback(onCancel)
@ -63,7 +63,7 @@ export default function EditableSpan(props: EditableSpanProps) {
if (editable) {
setIsSubmittable(checkSubmittableRef.current?.(inputRef.current?.value ?? '') ?? true)
}
}, [editable])
}, [checkSubmittableRef, editable])
React.useEffect(() => {
if (editable) {
@ -85,6 +85,7 @@ export default function EditableSpan(props: EditableSpanProps) {
aria.useInteractOutside({
ref: formRef,
isDisabled: !editable,
onInteractOutside: () => {
onCancel()
},
@ -109,16 +110,9 @@ export default function EditableSpan(props: EditableSpanProps) {
}}
>
<aria.Input
ref={inputRef}
data-testid={props['data-testid']}
className={tailwindMerge.twMerge('rounded-lg', className)}
ref={(element) => {
inputRef.current = element
if (element) {
element.style.width = '0'
element.style.width = `${element.scrollWidth}px`
}
}}
className={tailwindMerge.twMerge('flex-1 basis-full rounded-lg', className)}
type="text"
size={1}
defaultValue={children}
@ -129,10 +123,6 @@ export default function EditableSpan(props: EditableSpanProps) {
if (event.key !== 'Escape') {
event.stopPropagation()
}
if (event.target instanceof HTMLElement) {
event.target.style.width = '0'
event.target.style.width = `${event.target.scrollWidth}px`
}
}}
{...(inputPattern == null ? {} : { pattern: inputPattern })}
{...(inputTitle == null ? {} : { title: inputTitle })}
@ -144,7 +134,7 @@ export default function EditableSpan(props: EditableSpanProps) {
},
})}
/>
<ariaComponents.ButtonGroup gap="xsmall" className="grow-0 items-center">
<ariaComponents.ButtonGroup gap="xsmall" className="w-auto flex-none items-center">
{isSubmittable && (
<ariaComponents.Button
size="medium"

View File

@ -0,0 +1,80 @@
/**
* @file
*
* Isolates the layout of the children from the rest of the page.
* Improves Layout recalculation performance.
*/
import { useMeasureCallback } from '#/hooks/measureHooks'
import type { ForwardedRef } from 'react'
import { forwardRef, useRef, type ReactNode } from 'react'
import { mergeRefs } from '../utilities/mergeRefs'
import { tv } from '../utilities/tailwindVariants'
/**
* Props for the {@link IsolateLayout} component.
*/
export interface IsolateLayoutProps extends React.HTMLAttributes<HTMLDivElement> {
readonly useRAF?: boolean
readonly debounce?: number
readonly maxWait?: number
readonly children: ReactNode
}
const ISOLATE_LAYOUT_VARIANTS = tv({ base: 'contain-strict' })
const DEBOUNCE_TIME = 16
/**
* Isolates the layout of the children from the rest of the page, using SVG + foreignObject hack.
* Improves Layout recalculation performance.
*/
export const IsolateLayout = forwardRef(function IsolateLayout(
props: IsolateLayoutProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const {
className,
children,
style,
useRAF = false,
debounce = DEBOUNCE_TIME,
maxWait = DEBOUNCE_TIME,
...rest
} = props
const [measureRef] = useMeasureCallback({
onResize: ({ width, height }) => {
if (svgRef.current) {
svgRef.current.style.width = `${width}px`
svgRef.current.style.height = `${height}px`
}
if (foreignObjectRef.current) {
foreignObjectRef.current.style.width = `${width}px`
foreignObjectRef.current.style.height = `${height}px`
}
},
debounce,
maxWait,
useRAF,
})
const svgRef = useRef<SVGSVGElement>(null)
const foreignObjectRef = useRef<SVGForeignObjectElement>(null)
const classes = ISOLATE_LAYOUT_VARIANTS({ className })
return (
<div
ref={(node) => {
mergeRefs(ref, measureRef)(node)
}}
className={classes}
style={style}
{...rest}
>
<svg ref={svgRef} width="100%" height="100%">
<foreignObject ref={foreignObjectRef}>{children}</foreignObject>
</svg>
</div>
)
})

View File

@ -36,7 +36,9 @@ function Link(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
return (
<FocusRing>
<aria.Link
ref={mergeRefs(linkRef, ref)}
ref={(el) => {
mergeRefs(linkRef, ref)(el)
}}
href={to}
{...(openInBrowser && { target: '_blank' })}
rel="noopener noreferrer"

View File

@ -1,12 +1,10 @@
/** @file A selection brush to indicate the area being selected by the mouse drag action. */
import * as React from 'react'
import * as animationHooks from '#/hooks/animationHooks'
import * as modalProvider from '#/providers/ModalProvider'
import Portal from '#/components/Portal'
import * as animationHooks from '#/hooks/animationHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as modalProvider from '#/providers/ModalProvider'
import * as eventModule from '#/utilities/event'
import type * as geometry from '#/utilities/geometry'
import * as tailwindMerge from '#/utilities/tailwindMerge'
@ -36,16 +34,13 @@ export interface SelectionBrushProps {
/** A selection brush to indicate the area being selected by the mouse drag action. */
export default function SelectionBrush(props: SelectionBrushProps) {
const { targetRef, margin = 0, onDrag, onDragEnd, onDragCancel } = props
const { targetRef, margin = 0 } = props
const { modalRef } = modalProvider.useModalRef()
const isMouseDownRef = React.useRef(false)
const didMoveWhileDraggingRef = React.useRef(false)
const onDragRef = React.useRef(onDrag)
onDragRef.current = onDrag
const onDragEndRef = React.useRef(onDragEnd)
onDragEndRef.current = onDragEnd
const onDragCancelRef = React.useRef(onDragCancel)
onDragCancelRef.current = onDragCancel
const onDrag = useEventCallback(props.onDrag)
const onDragEnd = useEventCallback(props.onDragEnd)
const onDragCancel = useEventCallback(props.onDragCancel)
const lastMouseEvent = React.useRef<MouseEvent | null>(null)
const parentBounds = React.useRef<DOMRect | null>(null)
const anchorRef = React.useRef<geometry.Coordinate2D | null>(null)
@ -102,7 +97,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
}
const onMouseUp = (event: MouseEvent) => {
if (didMoveWhileDraggingRef.current) {
onDragEndRef.current(event)
onDragEnd(event)
}
// The `setTimeout` is required, otherwise the values are changed before the `onClick` handler
// is executed.
@ -145,7 +140,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
const onDragStart = () => {
if (isMouseDownRef.current) {
isMouseDownRef.current = false
onDragCancelRef.current()
onDragCancel()
unsetAnchor()
}
}
@ -162,7 +157,7 @@ export default function SelectionBrush(props: SelectionBrushProps) {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('click', onClick, { capture: true })
}
}, [margin, targetRef, modalRef])
}, [margin, targetRef, modalRef, onDragEnd, onDragCancel])
const rectangle = React.useMemo(() => {
if (position != null && lastSetAnchor != null) {
@ -192,9 +187,9 @@ export default function SelectionBrush(props: SelectionBrushProps) {
React.useEffect(() => {
if (selectionRectangle != null && lastMouseEvent.current != null) {
onDragRef.current(selectionRectangle, lastMouseEvent.current)
onDrag(selectionRectangle, lastMouseEvent.current)
}
}, [selectionRectangle])
}, [onDrag, selectionRectangle])
const brushStyle =
rectangle == null ?

View File

@ -2,7 +2,8 @@
* @file A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind
* classes.
*/
import { twMerge } from '#/utilities/tailwindMerge'
import * as React from 'react'
import { twJoin } from 'tailwind-merge'
/** The state of the spinner. It should go from `initial`, to `loading`, to `done`. */
export type SpinnerState = 'done' | 'initial' | 'loading-fast' | 'loading-medium' | 'loading-slow'
@ -27,14 +28,17 @@ export interface SpinnerProps {
}
/** A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind classes. */
export function Spinner(props: SpinnerProps) {
// eslint-disable-next-line no-restricted-syntax
export const Spinner = React.memo(function Spinner(props: SpinnerProps) {
const { size, padding, className, state } = props
const cssClasses = twJoin('pointer-events-none', className)
return (
<svg
width={size}
height={size}
className={className}
className={cssClasses}
style={{ padding }}
viewBox="0 0 24 24"
fill="none"
@ -51,11 +55,30 @@ export function Spinner(props: SpinnerProps) {
stroke="currentColor"
strokeLinecap="round"
strokeWidth={3}
className={twMerge(
className={twJoin(
'pointer-events-none origin-center !animate-spin-ease transition-stroke-dasharray [transition-duration:var(--spinner-slow-transition-duration)]',
SPINNER_CSS_CLASSES[state],
)}
/>
</svg>
)
})
/**
* Props for a {@link IndefiniteSpinner}.
*/
export interface IndefiniteSpinnerProps extends Omit<SpinnerProps, 'state'> {}
/**
* A spinning arc that animates indefinitely.
*/
export function IndefiniteSpinner(props: IndefiniteSpinnerProps) {
const { size, padding, className } = props
const cssClasses = twJoin(
'pointer-events-none flex-none contain-strict h-10 w-10 animate-spin ease-in-out rounded-full border-4 border-primary/10 border-l-primary',
className,
)
return <div className={cssClasses} style={{ padding, width: size, height: size }} />
}

View File

@ -20,11 +20,16 @@ export interface SvgMaskProps {
}
/** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */
export default function SvgMask(props: SvgMaskProps) {
function SvgMask(props: SvgMaskProps) {
const { invert = false, alt = '', src, style, color, className } = props
const urlSrc = `url(${JSON.stringify(src)})`
const mask = invert ? `${urlSrc}, linear-gradient(white 0 0)` : urlSrc
const classes = React.useMemo(
() => tailwindMerge.twMerge('inline-block h-max w-max flex-none', className),
[className],
)
return (
<div
style={{
@ -44,10 +49,12 @@ export default function SvgMask(props: SvgMaskProps) {
...(invert ? { WebkitMaskComposite: 'exclude, exclude' } : {}),
/* eslint-enable @typescript-eslint/naming-convention */
}}
className={tailwindMerge.twMerge('inline-block h-max w-max', className)}
className={classes}
>
{/* This is required for this component to have the right size. */}
<img alt={alt} src={src} className="pointer-events-none opacity-0" draggable={false} />
</div>
)
}
export default React.memo(SvgMask)

View File

@ -8,7 +8,11 @@ export type * from '@react-types/shared'
export * from 'react-aria'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from 'react-aria-components'
export { useTooltipTriggerState, type OverlayTriggerState } from 'react-stately'
export {
useTooltipTriggerState,
type OverlayTriggerState,
type TooltipTriggerState,
} from 'react-stately'
// ==================
// === mergeProps ===

View File

@ -9,6 +9,7 @@ import BlankIcon from '#/assets/blank.svg'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import type { DrivePastePayload } from '#/providers/DriveProvider'
import {
useDriveStore,
useSetSelectedKeys,
@ -20,7 +21,6 @@ import * as textProvider from '#/providers/TextProvider'
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
import * as columnModule from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import FocusRing from '#/components/styled/FocusRing'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
@ -38,13 +38,13 @@ import { useCutAndPaste } from '#/events/assetListEvent'
import {
backendMutationOptions,
backendQueryOptions,
useAsset,
useBackendMutationState,
useUploadFiles,
} from '#/hooks/backendHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useAsset } from '#/layouts/AssetsTable/assetsTableItemsHooks'
import { useFullUserSession } from '#/providers/AuthProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import { download } from '#/utilities/download'
@ -57,13 +57,12 @@ import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import invariant from 'tiny-invariant'
import { IndefiniteSpinner } from '../Spinner'
// =================
// === Constants ===
// =================
/** The height of the header row. */
const HEADER_HEIGHT_PX = 40
/**
* The amount of time (in milliseconds) the drag item must be held over this component
* to make a directory row expand.
@ -86,6 +85,7 @@ export interface AssetRowInnerProps {
/** Props for an {@link AssetRow}. */
export interface AssetRowProps {
readonly isOpened: boolean
readonly isPlaceholder: boolean
readonly visibility: Visibility | undefined
readonly id: backendModule.AssetId
@ -101,6 +101,7 @@ export interface AssetRowProps {
readonly grabKeyboardFocus: (item: backendModule.AnyAsset) => void
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
readonly select: (item: backendModule.AnyAsset) => void
readonly isExpanded: boolean
readonly onDragStart?: (
event: React.DragEvent<HTMLTableRowElement>,
item: backendModule.AnyAsset,
@ -121,6 +122,12 @@ export interface AssetRowProps {
event: React.DragEvent<HTMLTableRowElement>,
item: backendModule.AnyAsset,
) => void
readonly onCutAndPaste?: (
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId,
pasteData: DrivePastePayload,
nodeMap: ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>,
) => void
}
/** A row containing an {@link backendModule.AnyAsset}. */
@ -171,7 +178,7 @@ const AssetSpecialRow = React.memo(function AssetSpecialRow(props: AssetSpecialR
indent.indentClass(depth),
)}
>
<StatelessSpinner size={24} state="loading-medium" />
<IndefiniteSpinner size={24} />
</div>
</td>
</tr>
@ -234,16 +241,11 @@ type RealAssetRowProps = AssetRowProps & { readonly id: backendModule.RealAssetI
*/
// eslint-disable-next-line no-restricted-syntax
const RealAssetRow = React.memo(function RealAssetRow(props: RealAssetRowProps) {
const { id, parentId, state } = props
const { category, backend } = state
const { id } = props
const asset = useAsset({
backend,
parentId,
category,
assetId: id,
})
const asset = useAsset(id)
// should never happen since we only render real assets and they are always defined
if (asset == null) {
return null
}
@ -272,13 +274,14 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
columns,
onClick,
isPlaceholder,
isExpanded,
type,
asset,
} = props
const { path, hidden: hiddenRaw, grabKeyboardFocus, visibility: visibilityRaw, depth } = props
const { initialAssetEvents } = props
const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state
const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state
const { doRestore, doMove, category, rootDirectoryId, backend } = state
const driveStore = useDriveStore()
const queryClient = useQueryClient()
@ -295,12 +298,10 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
driveStore,
({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected,
)
const wasSoleSelectedRef = React.useRef(isSoleSelected)
const draggableProps = dragAndDropHooks.useDraggable()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const cutAndPaste = useCutAndPaste(category)
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const rootRef = React.useRef<HTMLElement | null>(null)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
@ -308,6 +309,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
assetRowUtils.INITIAL_ROW_STATE,
)
const cutAndPaste = useCutAndPaste(category)
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
@ -343,7 +345,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
backend,
}),
select: (data) => data.state.type,
enabled: asset.type === backendModule.AssetType.project && !isPlaceholder,
enabled: asset.type === backendModule.AssetType.project && !isPlaceholder && isOpened,
})
const toastAndLog = useToastAndLog()
@ -668,34 +670,13 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
ref={(element) => {
rootRef.current = element
requestAnimationFrame(() => {
if (
isSoleSelected &&
!wasSoleSelectedRef.current &&
element != null &&
scrollContainerRef.current != null
) {
const rect = element.getBoundingClientRect()
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
const scrollDown = rect.bottom - scrollRect.bottom
if (scrollUp < 0 || scrollDown > 0) {
scrollContainerRef.current.scrollBy({
top: scrollUp < 0 ? scrollUp : scrollDown,
behavior: 'smooth',
})
}
}
wasSoleSelectedRef.current = isSoleSelected
})
if (isKeyboardSelected && element?.contains(document.activeElement) === false) {
element.scrollIntoView({ block: 'nearest' })
element.focus()
}
}}
className={tailwindMerge.twMerge(
'h-table-row rounded-full transition-all ease-in-out rounded-rows-child [contain-intrinsic-size:40px] [content-visibility:auto]',
'h-table-row rounded-full transition-all ease-in-out rounded-rows-child',
visibility,
(isDraggedOver || selected) && 'selected',
)}
@ -831,6 +812,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
<Render
isPlaceholder={isPlaceholder}
isExpanded={isExpanded}
keyProp={id}
isOpened={isOpened}
backendType={backend.type}

View File

@ -6,7 +6,7 @@ import EditableSpan from '#/components/EditableSpan'
import type * as backendModule from '#/services/Backend'
import { useSetIsAssetPanelTemporarilyVisible } from '#/providers/DriveProvider'
import { useSetIsAssetPanelTemporarilyVisible } from '#/layouts/AssetPanel'
import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
@ -43,8 +43,8 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
return (
<div
className={tailwindMerge.twMerge(
'flex h-table-row min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
className={tailwindMerge.twJoin(
'flex h-table-row w-auto min-w-48 max-w-96 items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y contain-strict rounded-rows-child [contain-intrinsic-size:37px] [content-visibility:auto]',
indent.indentClass(depth),
)}
onKeyDown={(event) => {

View File

@ -16,6 +16,7 @@ import SvgMask from '#/components/SvgMask'
import * as backendModule from '#/services/Backend'
import { useStore } from '#/hooks/storeHooks'
import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
@ -39,11 +40,13 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {
*/
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, depth, selected, state, rowState, setRowState, isEditable } = props
const { backend, nodeMap, expandedDirectoryIds } = state
const { backend, nodeMap } = state
const { getText } = textProvider.useText()
const driveStore = useDriveStore()
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
const isExpanded = expandedDirectoryIds.includes(item.id)
const isExpanded = useStore(driveStore, (storeState) =>
storeState.expandedDirectoryIds.includes(item.id),
)
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
@ -68,8 +71,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
return (
<div
className={tailwindMerge.twMerge(
'group flex h-table-row min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
className={tailwindMerge.twJoin(
'group flex h-table-row w-auto min-w-48 max-w-96 items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y contain-strict rounded-rows-child [contain-intrinsic-size:37px] [content-visibility:auto]',
indent.indentClass(depth),
)}
onKeyDown={(event) => {
@ -94,7 +97,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
variant="custom"
aria-label={isExpanded ? getText('collapse') : getText('expand')}
tooltipPlacement="left"
className={tailwindMerge.twMerge(
className={tailwindMerge.twJoin(
'm-0 hidden cursor-pointer border-0 transition-transform duration-arrow group-hover:m-name-column-icon group-hover:inline-block',
isExpanded && 'rotate-90',
)}
@ -107,7 +110,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
data-testid="asset-row-name"
editable={rowState.isEditingName}
className={tailwindMerge.twMerge(
'grow cursor-pointer bg-transparent font-naming',
'cursor-pointer bg-transparent font-naming',
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer',
)}
checkSubmittable={(newTitle) =>

View File

@ -59,8 +59,8 @@ export default function FileNameColumn(props: FileNameColumnProps) {
return (
<div
className={tailwindMerge.twMerge(
'flex h-table-row min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
className={tailwindMerge.twJoin(
'flex h-table-row w-auto min-w-48 max-w-96 items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y contain-strict rounded-rows-child [contain-intrinsic-size:37px] [content-visibility:auto]',
indent.indentClass(depth),
)}
onKeyDown={(event) => {

View File

@ -6,6 +6,7 @@ import { Text } from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import { useHandleFocusMove } from '#/hooks/focusHooks'
import { useFocusDirection } from '#/providers/FocusDirectionProvider'
import type { Label as BackendLabel } from '#/services/Backend'
import { lChColorToCssColor, type LChColor } from '#/services/Backend'
import { twMerge } from '#/utilities/tailwindMerge'
@ -28,7 +29,11 @@ interface InternalLabelProps extends Readonly<PropsWithChildren> {
readonly draggable?: boolean
readonly color: LChColor
readonly title?: string
readonly onPress: (event: MouseEvent<HTMLButtonElement> | PressEvent) => void
readonly label?: BackendLabel
readonly onPress?: (
event: MouseEvent<HTMLButtonElement> | PressEvent,
label?: BackendLabel,
) => void
readonly onContextMenu?: (event: MouseEvent<HTMLElement>) => void
readonly onDragStart?: (event: DragEvent<HTMLElement>) => void
}
@ -36,7 +41,7 @@ interface InternalLabelProps extends Readonly<PropsWithChildren> {
/** An label that can be applied to an asset. */
export default function Label(props: InternalLabelProps) {
const { active = false, isDisabled = false, color, negated = false, draggable, title } = props
const { onPress, onDragStart, onContextMenu } = props
const { onPress, onDragStart, onContextMenu, label } = props
const { children: childrenRaw } = props
const focusDirection = useFocusDirection()
const handleFocusMove = useHandleFocusMove(focusDirection)
@ -67,7 +72,7 @@ export default function Label(props: InternalLabelProps) {
style={{ backgroundColor: lChColorToCssColor(color) }}
onClick={(event) => {
event.stopPropagation()
onPress(event)
onPress?.(event, label)
}}
onDragStart={(e) => {
onDragStart?.(e)

View File

@ -103,9 +103,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
const isOtherUserUsingProject =
isCloud && itemProjectState.openedBy != null && itemProjectState.openedBy !== user.email
const { data: users } = useBackendQuery(backend, 'listUsers', [], {
enabled: isOtherUserUsingProject,
})
const userOpeningProject = useMemo(
() =>
!isOtherUserUsingProject ? null : (
@ -113,6 +115,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
),
[isOtherUserUsingProject, itemProjectState.openedBy, users],
)
const userOpeningProjectTooltip =
userOpeningProject == null ? null : getText('xIsUsingTheProject', userOpeningProject.name)
@ -138,7 +141,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
const spinnerState = ((): SpinnerState => {
if (!isOpened) {
return 'initial'
return 'loading-slow'
} else if (isError) {
return 'initial'
} else if (status == null) {
@ -197,7 +200,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
/>
<StatelessSpinner
state={spinnerState}
className={tailwindMerge.twMerge(
className={tailwindMerge.twJoin(
'pointer-events-none absolute inset-0',
isRunningInBackground && 'text-green',
)}

View File

@ -95,8 +95,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
return (
<div
className={tailwindMerge.twMerge(
'flex h-table-row min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
className={tailwindMerge.twJoin(
'flex h-table-row w-auto min-w-48 max-w-96 items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y contain-strict rounded-rows-child [contain-intrinsic-size:37px] [content-visibility:auto]',
indent.indentClass(depth),
)}
onKeyDown={(event) => {

View File

@ -52,7 +52,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
return (
<div
className={tailwindMerge.twMerge(
'flex h-table-row min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
'flex h-table-row w-auto min-w-48 max-w-96 items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y contain-strict rounded-rows-child [contain-intrinsic-size:37px] [content-visibility:auto]',
indent.indentClass(depth),
)}
onKeyDown={(event) => {

View File

@ -15,13 +15,7 @@ export default function TheModal() {
return (
<AnimatePresence>
{modal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/* eslint-disable-next-line @typescript-eslint/no-magic-numbers */
transition={{ duration: 0.2 }}
>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<DialogTrigger key={key} defaultOpen>
{/* This component suppresses the warning about the target not being pressable element. */}
<Pressable>

View File

@ -1,5 +1,5 @@
/** @file Column types and column display modes. */
import type { Dispatch, JSX, SetStateAction } from 'react'
import { memo, type Dispatch, type JSX, type SetStateAction } from 'react'
import type { SortableColumn } from '#/components/dashboard/column/columnUtils'
import { Column } from '#/components/dashboard/column/columnUtils'
@ -33,6 +33,7 @@ export interface AssetColumnProps {
readonly setRowState: Dispatch<SetStateAction<AssetRowState>>
readonly isEditable: boolean
readonly isPlaceholder: boolean
readonly isExpanded: boolean
}
/** Props for a {@link AssetColumn}. */
@ -56,12 +57,14 @@ export interface AssetColumn {
// =======================
/** React components for every column. */
export const COLUMN_RENDERER: Readonly<Record<Column, (props: AssetColumnProps) => JSX.Element>> = {
[Column.name]: NameColumn,
[Column.modified]: ModifiedColumn,
[Column.sharedWith]: SharedWithColumn,
[Column.labels]: LabelsColumn,
[Column.accessedByProjects]: PlaceholderColumn,
[Column.accessedData]: PlaceholderColumn,
[Column.docs]: DocsColumn,
export const COLUMN_RENDERER: Readonly<
Record<Column, React.MemoExoticComponent<(props: AssetColumnProps) => React.JSX.Element>>
> = {
[Column.name]: memo(NameColumn),
[Column.modified]: memo(ModifiedColumn),
[Column.sharedWith]: memo(SharedWithColumn),
[Column.labels]: memo(LabelsColumn),
[Column.accessedByProjects]: memo(PlaceholderColumn),
[Column.accessedData]: memo(PlaceholderColumn),
[Column.docs]: memo(DocsColumn),
}

View File

@ -8,7 +8,7 @@ export default function DocsColumn(props: column.AssetColumnProps) {
const { item } = props
return (
<div className="flex max-w-drive-docs-column items-center gap-column-items overflow-hidden whitespace-nowrap">
<div className="flex max-w-drive-docs-column items-center gap-column-items overflow-hidden whitespace-nowrap contain-strict [contain-intrinsic-size:37px] [content-visibility:auto]">
{item.description}
</div>
)

View File

@ -45,7 +45,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
self?.permission === permissions.PermissionAction.admin)
return (
<div className="group flex items-center gap-column-items">
<div className="group flex items-center gap-column-items contain-strict [contain-intrinsic-size:37px] [content-visibility:auto]">
{(item.labels ?? [])
.filter((label) => labelsByName.has(label))
.map((label) => (
@ -93,7 +93,6 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
isDisabled
key={label}
color={labelsByName.get(label)?.color ?? backendModule.COLORS[0]}
onPress={() => {}}
>
{label}
</Label>

View File

@ -1,5 +1,5 @@
/** @file A column displaying the time at which the asset was last modified. */
import { Text } from '#/components/aria'
import { Text } from '#/components/AriaComponents'
import type { AssetColumnProps } from '#/components/dashboard/column'
import { formatDateTime } from '#/utilities/dateTime'
@ -7,5 +7,9 @@ import { formatDateTime } from '#/utilities/dateTime'
export default function ModifiedColumn(props: AssetColumnProps) {
const { item } = props
return <Text>{formatDateTime(new Date(item.modifiedAt))}</Text>
return (
<Text className="contain-strict [contain-intrinsic-size:37px] [content-visibility:auto]">
{formatDateTime(new Date(item.modifiedAt))}
</Text>
)
}

View File

@ -7,7 +7,6 @@ import type { AssetColumnProps } from '#/components/dashboard/column'
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
import { PaywallDialogButton } from '#/components/Paywall'
import AssetEventType from '#/events/AssetEventType'
import { useAssetStrict } from '#/hooks/backendHooks'
import { usePaywall } from '#/hooks/billing'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
@ -36,19 +35,14 @@ interface SharedWithColumnPropsInternal extends Pick<AssetColumnProps, 'item'> {
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, state, isReadonly = false } = props
const { backend, category, setQuery } = state
const asset = useAssetStrict({
backend,
assetId: item.id,
parentId: item.parentId,
category,
})
const { user } = useFullUserSession()
const dispatchAssetEvent = useDispatchAssetEvent()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
const assetPermissions = asset.permissions ?? []
const assetPermissions = item.permissions ?? []
const { setModal } = useSetModal()
const self = tryFindSelfPermission(user, asset.permissions)
const self = tryFindSelfPermission(user, item.permissions)
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const managesThisAsset =
!isReadonly &&
@ -56,7 +50,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
(self?.permission === PermissionAction.own || self?.permission === PermissionAction.admin)
return (
<div className="group flex items-center gap-column-items">
<div className="group flex items-center gap-column-items contain-strict [contain-intrinsic-size:37px] [content-visibility:auto]">
{(category.type === 'trash' ?
assetPermissions.filter((permission) => permission.permission === PermissionAction.own)
: assetPermissions
@ -103,11 +97,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
<ManagePermissionsModal
backend={backend}
category={category}
item={asset}
item={item}
self={self}
eventTarget={plusButtonRef.current}
doRemoveSelf={() => {
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: asset.id })
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: item.id })
}}
/>,
)

View File

@ -64,18 +64,18 @@ export const COLUMN_SHOW_TEXT_ID: Readonly<Record<Column, text.TextId>> = {
} satisfies { [C in Column]: `${C}ColumnShow` }
const COLUMN_CSS_CLASSES =
'text-left bg-clip-padding last:border-r-0 last:rounded-r-full last:w-full'
'max-w-96 text-left bg-clip-padding last:border-r-0 last:rounded-r-full last:w-full'
const NORMAL_COLUMN_CSS_CLASSES = `px-cell-x py ${COLUMN_CSS_CLASSES}`
/** CSS classes for every column. */
export const COLUMN_CSS_CLASS: Readonly<Record<Column, string>> = {
[Column.name]: `rounded-rows-skip-level min-w-drive-name-column h-full p-0 border-l-0 ${COLUMN_CSS_CLASSES}`,
[Column.modified]: `min-w-drive-modified-column ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.sharedWith]: `min-w-drive-shared-with-column ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.labels]: `min-w-drive-labels-column ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.accessedByProjects]: `min-w-drive-accessed-by-projects-column ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.accessedData]: `min-w-drive-accessed-data-column ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.docs]: `min-w-drive-docs-column ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.modified]: `min-w-drive-modified-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.sharedWith]: `min-w-drive-shared-with-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.labels]: `min-w-drive-labels-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.accessedByProjects]: `min-w-drive-accessed-by-projects-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.accessedData]: `min-w-drive-accessed-data-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.docs]: `min-w-drive-docs-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
}
// =====================

View File

@ -8,15 +8,19 @@ import LabelsColumnHeading from '#/components/dashboard/columnHeading/LabelsColu
import ModifiedColumnHeading from '#/components/dashboard/columnHeading/ModifiedColumnHeading'
import NameColumnHeading from '#/components/dashboard/columnHeading/NameColumnHeading'
import SharedWithColumnHeading from '#/components/dashboard/columnHeading/SharedWithColumnHeading'
import { memo } from 'react'
export const COLUMN_HEADING: Readonly<
Record<columnUtils.Column, (props: column.AssetColumnHeadingProps) => React.JSX.Element>
Record<
columnUtils.Column,
React.MemoExoticComponent<(props: column.AssetColumnHeadingProps) => React.JSX.Element>
>
> = {
[columnUtils.Column.name]: NameColumnHeading,
[columnUtils.Column.modified]: ModifiedColumnHeading,
[columnUtils.Column.sharedWith]: SharedWithColumnHeading,
[columnUtils.Column.labels]: LabelsColumnHeading,
[columnUtils.Column.accessedByProjects]: AccessedByProjectsColumnHeading,
[columnUtils.Column.accessedData]: AccessedDataColumnHeading,
[columnUtils.Column.docs]: DocsColumnHeading,
[columnUtils.Column.name]: memo(NameColumnHeading),
[columnUtils.Column.modified]: memo(ModifiedColumnHeading),
[columnUtils.Column.sharedWith]: memo(SharedWithColumnHeading),
[columnUtils.Column.labels]: memo(LabelsColumnHeading),
[columnUtils.Column.accessedByProjects]: memo(AccessedByProjectsColumnHeading),
[columnUtils.Column.accessedData]: memo(AccessedDataColumnHeading),
[columnUtils.Column.docs]: memo(DocsColumnHeading),
}

View File

@ -1,26 +1,24 @@
/** @file An area that contains focusable children. */
import * as React from 'react'
import { type JSX, type RefCallback, useMemo, useRef, useState } from 'react'
import * as detect from 'enso-common/src/detect'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import AreaFocusProvider from '#/providers/AreaFocusProvider'
import FocusClassesProvider, * as focusClassProvider from '#/providers/FocusClassProvider'
import type * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
import FocusClassesProvider, { useFocusClasses } from '#/providers/FocusClassProvider'
import type { FocusDirection } from '#/providers/FocusDirectionProvider'
import FocusDirectionProvider from '#/providers/FocusDirectionProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import { useNavigator2D } from '#/providers/Navigator2DProvider'
import * as aria from '#/components/aria'
import * as withFocusScope from '#/components/styled/withFocusScope'
import { type DOMAttributes, useFocusManager, useFocusWithin } from '#/components/aria'
import { withFocusScope } from '#/components/styled/withFocusScope'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
// =================
// === FocusArea ===
// =================
/** Props returned by {@link aria.useFocusWithin}. */
/** Props returned by {@link useFocusWithin}. */
export interface FocusWithinProps {
readonly ref: React.RefCallback<HTMLElement | SVGElement | null>
readonly onFocus: NonNullable<aria.DOMAttributes<Element>['onFocus']>
readonly onBlur: NonNullable<aria.DOMAttributes<Element>['onBlur']>
readonly ref: RefCallback<HTMLElement | SVGElement | null>
readonly onFocus: NonNullable<DOMAttributes<Element>['onFocus']>
readonly onBlur: NonNullable<DOMAttributes<Element>['onBlur']>
}
/** Props for a {@link FocusArea} */
@ -30,71 +28,81 @@ export interface FocusAreaProps {
/** Should ONLY be passed in exceptional cases. */
readonly focusDefaultClass?: string
readonly active?: boolean
readonly direction: focusDirectionProvider.FocusDirection
readonly children: (props: FocusWithinProps) => React.JSX.Element
readonly direction: FocusDirection
readonly children: (props: FocusWithinProps) => JSX.Element
}
/** An area that can be focused within. */
function FocusArea(props: FocusAreaProps) {
const { active = true, direction, children } = props
const { focusChildClass = 'focus-child', focusDefaultClass = 'focus-default' } = props
const { focusChildClass: outerFocusChildClass } = focusClassProvider.useFocusClasses()
const [areaFocus, setAreaFocus] = React.useState(false)
const { focusWithinProps } = aria.useFocusWithin({ onFocusWithinChange: setAreaFocus })
const focusManager = aria.useFocusManager()
const navigator2D = navigator2DProvider.useNavigator2D()
const rootRef = React.useRef<HTMLElement | SVGElement | null>(null)
const cleanupRef = React.useRef(() => {})
const focusChildClassRef = React.useRef(focusChildClass)
focusChildClassRef.current = focusChildClass
const focusDefaultClassRef = React.useRef(focusDefaultClass)
focusDefaultClassRef.current = focusDefaultClass
const { focusChildClass: outerFocusChildClass } = useFocusClasses()
const [areaFocus, setAreaFocus] = useState(false)
let isRealRun = !detect.IS_DEV_MODE
React.useEffect(() => {
return () => {
if (isRealRun) {
cleanupRef.current()
}
// This is INTENTIONAL. It may not be causing problems now, but is a defensive measure
// to make the implementation of this function consistent with the implementation of
// `FocusRoot`.
// eslint-disable-next-line react-hooks/exhaustive-deps
isRealRun = true
}
}, [])
const onChangeFocusWithin = useEventCallback((value: boolean) => {
if (value === areaFocus) return
setAreaFocus(value)
})
const cachedChildren = React.useMemo(
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: onChangeFocusWithin })
const focusManager = useFocusManager()
const navigator2D = useNavigator2D()
const rootRef = useRef<HTMLElement | SVGElement | null>(null)
const cleanupRef = useRef(() => {})
const focusChildClassRef = useSyncRef(focusChildClass)
const focusDefaultClassRef = useSyncRef(focusDefaultClass)
// The following group of functions are for suppressing `react-compiler` lints.
const cleanup = useEventCallback(() => {
cleanupRef.current()
})
const setRootRef = useEventCallback((value: HTMLElement | SVGElement | null) => {
rootRef.current = value
})
const setCleanupRef = useEventCallback((value: () => void) => {
cleanupRef.current = value
})
const focusFirst = useEventCallback(() =>
focusManager?.focusFirst({
accept: (other) => other.classList.contains(focusChildClassRef.current),
}),
)
const focusLast = useEventCallback(() =>
focusManager?.focusLast({
accept: (other) => other.classList.contains(focusChildClassRef.current),
}),
)
const focusCurrent = useEventCallback(
() =>
focusManager?.focusFirst({
accept: (other) => other.classList.contains(focusDefaultClassRef.current),
}) ?? focusFirst(),
)
const cachedChildren = useMemo(
() =>
// This is REQUIRED, otherwise `useFocusWithin` does not work with components from
// `react-aria-components`.
// eslint-disable-next-line no-restricted-syntax
children({
ref: (element) => {
rootRef.current = element
cleanupRef.current()
setRootRef(element)
cleanup()
if (active && element != null && focusManager != null) {
const focusFirst = focusManager.focusFirst.bind(null, {
accept: (other) => other.classList.contains(focusChildClassRef.current),
})
const focusLast = focusManager.focusLast.bind(null, {
accept: (other) => other.classList.contains(focusChildClassRef.current),
})
const focusCurrent = () =>
focusManager.focusFirst({
accept: (other) => other.classList.contains(focusDefaultClassRef.current),
}) ?? focusFirst()
cleanupRef.current = navigator2D.register(element, {
focusPrimaryChild: focusCurrent,
focusWhenPressed:
direction === 'horizontal' ?
{ right: focusFirst, left: focusLast }
: { down: focusFirst, up: focusLast },
})
setCleanupRef(
navigator2D.register(element, {
focusPrimaryChild: focusCurrent,
focusWhenPressed:
direction === 'horizontal' ?
{ right: focusFirst, left: focusLast }
: { down: focusFirst, up: focusLast },
}),
)
} else {
cleanupRef.current = () => {}
setCleanupRef(() => {})
}
if (element != null && detect.IS_DEV_MODE) {
if (element != null && IS_DEV_MODE) {
if (active) {
element.dataset.focusArea = ''
} else {
@ -104,7 +112,20 @@ function FocusArea(props: FocusAreaProps) {
},
...focusWithinProps,
} as FocusWithinProps),
[active, direction, children, focusManager, focusWithinProps, navigator2D],
[
children,
focusWithinProps,
setRootRef,
cleanup,
active,
focusManager,
setCleanupRef,
navigator2D,
focusCurrent,
direction,
focusFirst,
focusLast,
],
)
const result = (
@ -118,4 +139,4 @@ function FocusArea(props: FocusAreaProps) {
}
/** An area that can be focused within. */
export default withFocusScope.withFocusScope(FocusArea)
export default withFocusScope(FocusArea)

View File

@ -1,5 +1,4 @@
/** @file A styled focus ring. */
import * as React from 'react'
import * as aria from '#/components/aria'

View File

@ -30,20 +30,6 @@ function FocusRoot(props: FocusRootProps) {
const navigator2D = navigator2DProvider.useNavigator2D()
const cleanupRef = React.useRef(() => {})
let isRealRun = !detect.IS_DEV_MODE
React.useEffect(() => {
return () => {
if (isRealRun) {
cleanupRef.current()
}
// This is INTENTIONAL. The first time this hook runs, when in Strict Mode, is *after* the ref
// has already been set. This makes the focus root immediately unset itself,
// which is incorrect behavior.
// eslint-disable-next-line react-hooks/exhaustive-deps
isRealRun = true
}
}, [])
const cachedChildren = React.useMemo(
() =>
children({

View File

@ -112,17 +112,23 @@ function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef<HTMLDiv
})
const [labelRef, label] = useSlot()
const { radioGroupProps, labelProps, descriptionProps, errorMessageProps, ...validation } =
aria.useRadioGroup(
{
...props,
label,
validationBehavior: props.validationBehavior ?? 'native',
},
state,
)
// This single line is the reason this file exists!
delete radioGroupProps.onKeyDown
const {
// This single line is the reason this file exists!
// Omit the default `onKeyDown` handler from the return value of this hook.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
radioGroupProps: { onKeyDown, ...radioGroupProps },
labelProps,
descriptionProps,
errorMessageProps,
...validation
} = aria.useRadioGroup(
{
...props,
label,
validationBehavior: props.validationBehavior ?? 'native',
},
state,
)
const renderProps = useRenderProps({
...props,

View File

@ -1,4 +1,6 @@
/** @file Hooks for. */
import { useSyncRef } from '#/hooks/syncRefHooks'
import { unsafeWriteValue } from '#/utilities/write'
import * as React from 'react'
// =================
@ -68,8 +70,7 @@ export function useAutoScroll(
const animationFrameHandle = React.useRef(0)
const pointerX = React.useRef(0)
const pointerY = React.useRef(0)
const optionsRef = React.useRef(options)
optionsRef.current = options
const optionsRef = useSyncRef(options)
const onMouseEvent = React.useCallback((event: MouseEvent | React.MouseEvent) => {
pointerX.current = event.clientX
@ -97,13 +98,21 @@ export function useAutoScroll(
if (scrollContainer.scrollTop > 0) {
const distanceToTop = Math.max(0, pointerY.current - rect.top - insetTop)
if (distanceToTop < threshold) {
scrollContainer.scrollTop -= Math.floor(speed / (distanceToTop + falloff))
unsafeWriteValue(
scrollContainer,
'scrollTop',
scrollContainer.scrollTop - Math.floor(speed / (distanceToTop + falloff)),
)
}
}
if (scrollContainer.scrollTop + rect.height < scrollContainer.scrollHeight) {
const distanceToBottom = Math.max(0, rect.bottom - pointerY.current - insetBottom)
if (distanceToBottom < threshold) {
scrollContainer.scrollTop += Math.floor(speed / (distanceToBottom + falloff))
unsafeWriteValue(
scrollContainer,
'scrollTop',
scrollContainer.scrollTop + Math.floor(speed / (distanceToBottom + falloff)),
)
}
}
}
@ -111,19 +120,27 @@ export function useAutoScroll(
if (scrollContainer.scrollLeft > 0) {
const distanceToLeft = Math.max(0, pointerX.current - rect.top - insetLeft)
if (distanceToLeft < threshold) {
scrollContainer.scrollLeft -= Math.floor(speed / (distanceToLeft + falloff))
unsafeWriteValue(
scrollContainer,
'scrollLeft',
scrollContainer.scrollLeft - Math.floor(speed / (distanceToLeft + falloff)),
)
}
}
if (scrollContainer.scrollLeft + rect.width < scrollContainer.scrollWidth) {
const distanceToRight = Math.max(0, rect.right - pointerX.current - insetRight)
if (distanceToRight < threshold) {
scrollContainer.scrollLeft += Math.floor(speed / (distanceToRight + falloff))
unsafeWriteValue(
scrollContainer,
'scrollLeft',
scrollContainer.scrollLeft + Math.floor(speed / (distanceToRight + falloff)),
)
}
}
}
animationFrameHandle.current = requestAnimationFrame(onAnimationFrame)
}
}, [scrollContainerRef])
}, [optionsRef, scrollContainerRef])
const startAutoScroll = React.useCallback(() => {
if (!isScrolling.current) {

View File

@ -202,6 +202,8 @@ const INVALIDATION_MAP: Partial<
createDirectory: ['listDirectory'],
createSecret: ['listDirectory'],
updateSecret: ['listDirectory'],
updateProject: ['listDirectory'],
updateDirectory: ['listDirectory'],
createDatalink: ['listDirectory', 'getDatalink'],
uploadFileEnd: ['listDirectory'],
copyAsset: ['listDirectory', 'listAssetVersions'],
@ -209,7 +211,6 @@ const INVALIDATION_MAP: Partial<
undoDeleteAsset: ['listDirectory'],
updateAsset: ['listDirectory', 'listAssetVersions'],
closeProject: ['listDirectory', 'listAssetVersions'],
updateDirectory: ['listDirectory'],
}
/** The type of the corresponding mutation for the given backend method. */
@ -318,6 +319,10 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) {
recentProjects: category.type === 'recent',
},
] as const,
// Setting stale time to Infinity to attaching a ton of
// setTimeouts to the query. Improves performance.
// Anyways, refetching is handled by another query.
staleTime: Infinity,
queryFn: async () => {
try {
return await backend.listDirectory(
@ -1174,12 +1179,20 @@ export function useUploadFileMutation(backend: Backend, options: UploadFileMutat
toastAndLog('uploadLargeFileError', error)
},
} = options
const uploadFileStartMutation = useMutation(backendMutationOptions(backend, 'uploadFileStart'))
const uploadFileStartMutation = useMutation(
useMemo(() => backendMutationOptions(backend, 'uploadFileStart'), [backend]),
)
const uploadFileChunkMutation = useMutation(
backendMutationOptions(backend, 'uploadFileChunk', { retry: chunkRetries }),
useMemo(
() => backendMutationOptions(backend, 'uploadFileChunk', { retry: chunkRetries }),
[backend, chunkRetries],
),
)
const uploadFileEndMutation = useMutation(
backendMutationOptions(backend, 'uploadFileEnd', { retry: endRetries }),
useMemo(
() => backendMutationOptions(backend, 'uploadFileEnd', { retry: endRetries }),
[backend, endRetries],
),
)
const [variables, setVariables] =
useState<[params: backendModule.UploadFileRequestParams, file: File]>()

View File

@ -5,12 +5,9 @@ import * as modalProvider from '#/providers/ModalProvider'
import ContextMenu from '#/components/ContextMenu'
import ContextMenus from '#/components/ContextMenus'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
// ======================
// === contextMenuRef ===
// ======================
/**
* Return a ref that attaches a context menu event listener.
* Should be used ONLY if the element does not expose an `onContextMenu` prop.
@ -22,11 +19,11 @@ export function useContextMenuRef(
options: { enabled?: boolean } = {},
) {
const { setModal } = modalProvider.useSetModal()
const createEntriesRef = React.useRef(createEntries)
createEntriesRef.current = createEntries
const stableCreateEntries = useEventCallback(createEntries)
const optionsRef = useSyncRef(options)
const cleanupRef = React.useRef(() => {})
const contextMenuRef = React.useMemo(
return React.useMemo(
() => (element: HTMLElement | null) => {
cleanupRef.current()
if (element == null) {
@ -36,7 +33,7 @@ export function useContextMenuRef(
const { enabled = true } = optionsRef.current
if (enabled) {
const position = { pageX: event.pageX, pageY: event.pageY }
const children = createEntriesRef.current(position)
const children = stableCreateEntries(position)
if (children != null) {
event.preventDefault()
event.stopPropagation()
@ -64,7 +61,6 @@ export function useContextMenuRef(
}
}
},
[key, label, optionsRef, setModal],
[stableCreateEntries, key, label, optionsRef, setModal],
)
return contextMenuRef
}

View File

@ -1,22 +1,20 @@
/**
* @file
*
* This file contains the `useDebouncedCallback` hook which is used to debounce a callback function.
*/
/** @file A hook to debounce a callback function. */
import * as React from 'react'
import { useEventCallback } from './eventCallbackHooks'
import { useUnmount } from './unmountHooks'
/** Wrap a callback into debounce function */
/** Wrap a callback into a debounced function */
export function useDebouncedCallback<Fn extends (...args: never[]) => unknown>(
callback: Fn,
delay: number,
maxWait: number | null = null,
): DebouncedFunction<Fn> {
const stableCallback = useEventCallback(callback)
const timeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>()
const waitTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>()
const lastCallRef = React.useRef<{ args: Parameters<Fn> }>()
const clear = useEventCallback(() => {

View File

@ -72,6 +72,8 @@ export function useMonitorDependencies(
console.groupEnd()
}
}
// Unavoidable. The ref must be updated only after logging is complete.
// eslint-disable-next-line react-compiler/react-compiler
oldDependenciesRef.current = dependencies
}
@ -82,13 +84,15 @@ export function useMonitorDependencies(
/** A modified `useEffect` that logs the old and new values of changed dependencies. */
export function useDebugEffect(
effect: React.EffectCallback,
deps: React.DependencyList,
dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[],
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
useMonitorDependencies(dependencies, description, dependencyDescriptions)
// Unavoidable as this is a wrapped hook.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(effect, deps)
React.useEffect(effect, dependencies)
}
// === useDebugMemo ===
@ -96,13 +100,15 @@ export function useDebugEffect(
/** A modified `useMemo` that logs the old and new values of changed dependencies. */
export function useDebugMemo<T>(
factory: () => T,
deps: React.DependencyList,
dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[],
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
useMonitorDependencies(dependencies, description, dependencyDescriptions)
// Unavoidable as this is a wrapped hook.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useMemo<T>(factory, deps)
return React.useMemo<T>(factory, dependencies)
}
// === useDebugCallback ===
@ -110,11 +116,13 @@ export function useDebugMemo<T>(
/** A modified `useCallback` that logs the old and new values of changed dependencies. */
export function useDebugCallback<T extends (...args: never[]) => unknown>(
callback: T,
deps: React.DependencyList,
dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[],
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
useMonitorDependencies(dependencies, description, dependencyDescriptions)
// Unavoidable as this is a wrapped hook.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useCallback<T>(callback, deps)
return React.useCallback<T>(callback, dependencies)
}

View File

@ -1,10 +1,16 @@
/** @file Track changes in intersection ratio between an element and one of its ancestors. */
import { useSyncRef } from '#/hooks/syncRefHooks'
import * as React from 'react'
// ============================
// === useIntersectionRatio ===
// ============================
// UNSAFE. Only type-safe if the `transform` and `initialValue` arguments below are omitted
// and the generic parameter is not explicitly specified.
// eslint-disable-next-line no-restricted-syntax
const DEFAULT_TRANSFORM = (ratio: number) => ratio as never
export function useIntersectionRatio(
rootRef: Readonly<React.MutableRefObject<HTMLDivElement | null>> | null,
targetRef: Readonly<React.MutableRefObject<HTMLElement | SVGElement | null>>,
@ -35,11 +41,7 @@ export function useIntersectionRatio<T>(
// `initialValue` is guaranteed to be the right type by the overloads.
// eslint-disable-next-line no-restricted-syntax
const [value, setValue] = React.useState((initialValue === undefined ? 0 : initialValue) as T)
// eslint-disable-next-line no-restricted-syntax
const transformRef = React.useRef(transform ?? ((ratio: number) => ratio as never))
if (transform) {
transformRef.current = transform
}
const transformRef = useSyncRef(transform ?? DEFAULT_TRANSFORM)
React.useEffect(() => {
const root = rootRef?.current ?? document.body
@ -88,7 +90,7 @@ export function useIntersectionRatio<T>(
} else {
return
}
}, [targetRef, rootRef, threshold])
}, [targetRef, rootRef, threshold, transformRef])
return value
}

View File

@ -44,6 +44,11 @@ interface State {
readonly lastBounds: RectReadOnly
}
/**
* A type that represents a callback that is called when the element is resized.
*/
export type OnResizeCallback = (bounds: RectReadOnly) => void
/**
* A type that represents the options for the useMeasure hook.
*/
@ -53,20 +58,23 @@ export interface Options {
| { readonly scroll: number; readonly resize: number; readonly frame: number }
readonly scroll?: boolean
readonly offsetSize?: boolean
readonly onResize?: (bounds: RectReadOnly) => void
readonly onResize?: OnResizeCallback
readonly maxWait?:
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
/**
* Whether to use RAF to measure the element.
*/
readonly useRAF?: boolean
}
/**
* Custom hook to measure the size and position of an element
*/
export function useMeasure(options: Options = {}): Result {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const { debounce = 0, scroll = false, offsetSize = false, onResize, maxWait = 500 } = options
const { onResize } = options
const [bounds, set] = useState<RectReadOnly>(() => ({
const [bounds, set] = useState<RectReadOnly>({
left: 0,
top: 0,
width: 0,
@ -75,14 +83,57 @@ export function useMeasure(options: Options = {}): Result {
right: 0,
x: 0,
y: 0,
}))
})
const onResizeStableCallback = useEventCallback<OnResizeCallback>((nextBounds) => {
startTransition(() => {
set(nextBounds)
})
onResize?.(nextBounds)
})
const [ref, forceRefresh] = useMeasureCallback({ ...options, onResize: onResizeStableCallback })
return [ref, bounds, forceRefresh] as const
}
const DEFAULT_MAX_WAIT = 500
/**
* Same as useMeasure, but doesn't rerender the component when the element is resized.
* Instead, it calls the `onResize` callback with the new bounds. This is useful when you want to
* measure the size of an element without causing a rerender.
*/
export function useMeasureCallback(options: Options & Required<Pick<Options, 'onResize'>>) {
const {
debounce = 0,
scroll = false,
offsetSize = false,
onResize,
maxWait = DEFAULT_MAX_WAIT,
useRAF = true,
} = options
// keep all state in a ref
const state = useRef<State>({
element: null,
scrollContainers: null,
lastBounds: bounds,
lastBounds: {
left: 0,
top: 0,
width: 0,
height: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
},
})
// make sure to update state only as long as the component is truly mounted
const mounted = useRef(false)
const onResizeStableCallback = useEventCallback<OnResizeCallback>(onResize)
const scrollMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.scroll
const resizeMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.resize
@ -92,12 +143,6 @@ export function useMeasure(options: Options = {}): Result {
const scrollDebounce = typeof debounce === 'number' ? debounce : debounce.scroll
const resizeDebounce = typeof debounce === 'number' ? debounce : debounce.resize
const frameDebounce = typeof debounce === 'number' ? debounce : debounce.frame
// make sure to update state only as long as the component is truly mounted
const mounted = useRef(false)
useUnmount(() => {
mounted.current = false
})
const callback = useEventCallback(() => {
frame.read(() => {
@ -113,21 +158,19 @@ export function useMeasure(options: Options = {}): Result {
}
if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {
startTransition(() => {
set((unsafeMutable(state.current).lastBounds = size))
onResize?.(size)
})
unsafeMutable(state.current).lastBounds = size
onResizeStableCallback(size)
}
})
})
const [resizeObserver] = useState(() => new ResizeObserver(callback))
const [mutationObserver] = useState(() => new MutationObserver(callback))
const frameDebounceCallback = useDebouncedCallback(callback, frameDebounce, frameMaxWait)
const resizeDebounceCallback = useDebouncedCallback(callback, resizeDebounce, resizeMaxWait)
const scrollDebounceCallback = useDebouncedCallback(callback, scrollDebounce, scrollMaxWait)
const [resizeObserver] = useState(() => new ResizeObserver(resizeDebounceCallback))
const [mutationObserver] = useState(() => new MutationObserver(resizeDebounceCallback))
const forceRefresh = useDebouncedCallback(callback, 0)
// cleanup current scroll-listeners / observers
@ -152,7 +195,9 @@ export function useMeasure(options: Options = {}): Result {
attributeFilter: ['style', 'class'],
})
frame.read(frameDebounceCallback, true)
if (useRAF) {
frame.read(frameDebounceCallback, true)
}
if (scroll && state.current.scrollContainers) {
state.current.scrollContainers.forEach((scrollContainer) => {
@ -173,6 +218,9 @@ export function useMeasure(options: Options = {}): Result {
unsafeMutable(state.current).element = node
unsafeMutable(state.current).scrollContainers = findScrollContainers(node)
callback()
addListeners()
})
@ -184,11 +232,11 @@ export function useMeasure(options: Options = {}): Result {
useEffect(() => {
removeListeners()
addListeners()
}, [scroll, scrollDebounceCallback, resizeDebounceCallback, removeListeners, addListeners])
}, [useRAF, scroll, removeListeners, addListeners])
useUnmount(removeListeners)
return [ref, bounds, forceRefresh]
return [ref, forceRefresh] as const
}
/**

View File

@ -1,8 +1,4 @@
/**
* @file
*
* Provides set of hooks to work with offline status
*/
/** @file Hooks to work with offline status. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
@ -53,7 +49,10 @@ export function useOfflineChange(
}
})
// Unavoidable.
// eslint-disable-next-line react-compiler/react-compiler
if (!triggeredImmediateRef.current) {
// eslint-disable-next-line react-compiler/react-compiler
triggeredImmediateRef.current = true
if (triggerImmediate === 'if-offline' && isOffline) {

View File

@ -39,6 +39,8 @@ const CLOUD_OPENING_INTERVAL_MS = 5_000
*/
const ACTIVE_SYNC_INTERVAL_MS = 100
const DEFAULT_INTERVAL_MS = 120_000
/** Options for {@link createGetProjectDetailsQuery}. */
export interface CreateOpenedProjectQueryOptions {
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
@ -92,24 +94,36 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp
networkMode: backend.type === backendModule.BackendType.remote ? 'online' : 'always',
refetchInterval: ({ state }) => {
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
const openingStates = [
backendModule.ProjectState.openInProgress,
backendModule.ProjectState.closing,
]
if (state.status === 'error') {
return false
}
if (state.data == null) {
return false
}
if (isLocal) {
if (state.data?.state.type === backendModule.ProjectState.opened) {
if (states.includes(state.data.state.type)) {
return OPENED_INTERVAL_MS
} else {
} else if (openingStates.includes(state.data.state.type)) {
return ACTIVE_SYNC_INTERVAL_MS
} else {
return DEFAULT_INTERVAL_MS
}
}
// Cloud project
if (states.includes(state.data.state.type)) {
return OPENED_INTERVAL_MS
} else if (openingStates.includes(state.data.state.type)) {
return CLOUD_OPENING_INTERVAL_MS
} else {
if (state.data == null) {
return ACTIVE_SYNC_INTERVAL_MS
} else if (states.includes(state.data.state.type)) {
return OPENED_INTERVAL_MS
} else {
return CLOUD_OPENING_INTERVAL_MS
}
return DEFAULT_INTERVAL_MS
}
},
})
@ -299,6 +313,7 @@ export function useOpenProject() {
...openingProjectMutation.options,
scope: { id: project.id },
})
addLaunchedProject(project)
}
})

View File

@ -1,11 +1,9 @@
/** @file Execute a function on scroll. */
import * as React from 'react'
import { useRef, useState, type MutableRefObject, type RefObject } from 'react'
import { useSyncRef } from '#/hooks/syncRefHooks'
import useOnScroll from '#/hooks/useOnScroll'
// ====================================
// === useStickyTableHeaderOnScroll ===
// ====================================
import { unsafeWriteValue } from '#/utilities/write'
/** Options for the {@link useStickyTableHeaderOnScroll} hook. */
interface UseStickyTableHeaderOnScrollOptions {
@ -19,21 +17,24 @@ interface UseStickyTableHeaderOnScrollOptions {
*
* NOTE: The returned event handler should be attached to the scroll container
* (the closest ancestor element with `overflow-y-auto`).
* @param rootRef - a {@link React.useRef} to the scroll container
* @param bodyRef - a {@link React.useRef} to the `tbody` element that needs to be clipped.
* @param rootRef - a {@link useRef} to the scroll container
* @param bodyRef - a {@link useRef} to the `tbody` element that needs to be clipped.
*/
export function useStickyTableHeaderOnScroll(
rootRef: React.MutableRefObject<HTMLDivElement | null>,
bodyRef: React.RefObject<HTMLTableSectionElement>,
rootRef: MutableRefObject<HTMLDivElement | null>,
bodyRef: RefObject<HTMLTableSectionElement>,
options: UseStickyTableHeaderOnScrollOptions = {},
) {
const { trackShadowClass = false } = options
const trackShadowClassRef = React.useRef(trackShadowClass)
trackShadowClassRef.current = trackShadowClass
const [shadowClassName, setShadowClass] = React.useState('')
const trackShadowClassRef = useSyncRef(trackShadowClass)
const [shadowClassName, setShadowClass] = useState('')
const onScroll = useOnScroll(() => {
if (rootRef.current != null && bodyRef.current != null) {
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
unsafeWriteValue(
bodyRef.current.style,
'clipPath',
`inset(${rootRef.current.scrollTop}px 0 0 0)`,
)
if (trackShadowClassRef.current) {
const isAtTop = rootRef.current.scrollTop === 0
const isAtBottom =
@ -47,6 +48,6 @@ export function useStickyTableHeaderOnScroll(
setShadowClass(newShadowClass)
}
}
}, [bodyRef, rootRef])
}, [bodyRef, rootRef, trackShadowClassRef])
return { onScroll, shadowClassName }
}

View File

@ -72,7 +72,7 @@ export function useStore<State, Slice>(
export function useTearingTransitionStore<State, Slice>(
store: StoreApi<State>,
selector: (state: State) => Slice,
areEqual: AreEqual<Slice> = 'object',
areEqual: AreEqual<Slice> = 'shallow',
) {
const state = store.getState()
@ -93,10 +93,12 @@ export function useTearingTransitionStore<State, Slice>(
if (Object.is(prev[2], nextState) && prev[1] === store) {
return prev
}
const nextSlice = selector(nextState)
if (equalityFunction(prev[0], nextSlice) && prev[1] === store) {
return prev
}
return [nextSlice, store, nextState]
},
undefined,

View File

@ -2,7 +2,7 @@
import { type MutableRefObject, useRef } 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<MutableRefObject<T>> {
export function useSyncRef<T>(value: T): MutableRefObject<T> {
const ref = useRef(value)
/*

View File

@ -1,14 +1,13 @@
/**
* @file
*
* A hook that returns a memoized function that will only be called once
*/
/** @file A hook that returns a memoized function that will only be called once. */
import * as React from 'react'
const UNSET_VALUE = Symbol('unset')
/** A hook that returns a memoized function that will only be called once */
export function useLazyMemoHooks<T>(factory: T | (() => T), deps: React.DependencyList): () => T {
export function useLazyMemoHooks<T>(
factory: T | (() => T),
dependencies: React.DependencyList,
): () => T {
return React.useMemo(() => {
let cachedValue: T | typeof UNSET_VALUE = UNSET_VALUE
@ -19,8 +18,9 @@ export function useLazyMemoHooks<T>(factory: T | (() => T), deps: React.Dependen
return cachedValue
}
// We assume that the callback should change only when
// the deps change.
// We assume that the callback should change only when the deps change.
// Unavoidable, the dependency list is pased through transparently.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}, dependencies)
}

View File

@ -30,6 +30,8 @@ export default function useOnScroll(callback: () => void, dependencies: React.De
React.useLayoutEffect(() => {
updateClipPathRef.current()
// Unavoidable, the dependency list is pased through transparently.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies)

View File

@ -36,15 +36,12 @@ import * as backendModule from '#/services/Backend'
import * as localBackendModule from '#/services/LocalBackend'
import { useNewProject, useUploadFileWithToastMutation } from '#/hooks/backendHooks'
import {
usePasteData,
useSetAssetPanelProps,
useSetIsAssetPanelTemporarilyVisible,
} from '#/providers/DriveProvider'
import { usePasteData } from '#/providers/DriveProvider'
import { normalizePath } from '#/utilities/fileInfo'
import { mapNonNullish } from '#/utilities/nullable'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import { useSetAssetPanelProps, useSetIsAssetPanelTemporarilyVisible } from './AssetPanel'
// ========================
// === AssetContextMenu ===

View File

@ -3,25 +3,30 @@ import { MarkdownViewer } from '#/components/MarkdownViewer'
import { Result } from '#/components/Result'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import type { AnyAsset, Asset } from '#/services/Backend'
import type { Asset } from '#/services/Backend'
import { AssetType } from '#/services/Backend'
import { useStore } from '#/utilities/zustand'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import * as ast from 'ydoc-shared/ast'
import { splitFileContents } from 'ydoc-shared/ensoFile'
import { versionContentQueryOptions } from '../AssetDiffView/useFetchVersionContent'
import { assetPanelStore } from '../AssetPanel'
/** Props for a {@link AssetDocs}. */
export interface AssetDocsProps {
readonly backend: Backend
readonly item: AnyAsset | null
}
/** Documentation display for an asset. */
export function AssetDocs(props: AssetDocsProps) {
const { backend, item } = props
const { backend } = props
const { getText } = useText()
const { item } = useStore(assetPanelStore, (state) => ({ item: state.assetPanelProps.item }), {
unsafeEnableTransition: true,
})
if (item?.type !== AssetType.project) {
return <Result status="info" title={getText('assetDocs.notProject')} centered />
}

View File

@ -9,71 +9,32 @@ import sessionsIcon from '#/assets/group.svg'
import inspectIcon from '#/assets/inspect.svg'
import versionsIcon from '#/assets/versions.svg'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useBackend } from '#/providers/BackendProvider'
import {
useAssetPanelProps,
useAssetPanelSelectedTab,
useIsAssetPanelExpanded,
useIsAssetPanelHidden,
useSetAssetPanelSelectedTab,
useSetIsAssetPanelExpanded,
} from '#/providers/DriveProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import type { AnyAsset, BackendType } from 'enso-common/src/services/Backend'
import type { Spring } from 'framer-motion'
import { useStore } from '#/utilities/zustand'
import type { BackendType } from 'enso-common/src/services/Backend'
import { AnimatePresence, motion } from 'framer-motion'
import { memo, startTransition } from 'react'
import { z } from 'zod'
import { AssetDocs } from '../AssetDocs'
import AssetProjectSessions from '../AssetProjectSessions'
import type { AssetPropertiesSpotlight } from '../AssetProperties'
import AssetProperties from '../AssetProperties'
import AssetVersions from '../AssetVersions/AssetVersions'
import type { Category } from '../CategorySwitcher/Category'
import {
assetPanelStore,
useIsAssetPanelExpanded,
useSetIsAssetPanelExpanded,
} from './AssetPanelState'
import { AssetPanelTabs } from './components/AssetPanelTabs'
import { AssetPanelToggle } from './components/AssetPanelToggle'
import { type AssetPanelTab } from './types'
const ASSET_SIDEBAR_COLLAPSED_WIDTH = 48
const ASSET_PANEL_WIDTH = 480
const ASSET_PANEL_TOTAL_WIDTH = ASSET_PANEL_WIDTH + ASSET_SIDEBAR_COLLAPSED_WIDTH
/** Determines the content of the {@link AssetPanel}. */
const ASSET_PANEL_TABS = ['settings', 'versions', 'sessions', 'schedules', 'docs'] as const
/** Determines the content of the {@link AssetPanel}. */
type AssetPanelTab = (typeof ASSET_PANEL_TABS)[number]
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly isAssetPanelVisible: boolean
readonly isAssetPanelHidden: boolean
readonly assetPanelTab: AssetPanelTab
readonly assetPanelWidth: number
}
}
const ASSET_PANEL_TAB_SCHEMA = z.enum(ASSET_PANEL_TABS)
LocalStorage.register({
assetPanelTab: { schema: ASSET_PANEL_TAB_SCHEMA },
assetPanelWidth: { schema: z.number().int() },
isAssetPanelHidden: { schema: z.boolean() },
isAssetPanelVisible: { schema: z.boolean() },
})
/** Props supplied by the row. */
export interface AssetPanelContextProps {
readonly backend: Backend | null
readonly selectedTab: AssetPanelTab
readonly item: AnyAsset | null
readonly path: string | null
readonly spotlightOn: AssetPropertiesSpotlight | null
}
/**
* Props for an {@link AssetPanel}.
*/
@ -82,66 +43,76 @@ export interface AssetPanelProps {
readonly category: Category
}
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
}
/**
* The asset panel is a sidebar that can be expanded or collapsed.
* It is used to view and interact with assets in the drive.
*/
export function AssetPanel(props: AssetPanelProps) {
const isHidden = useIsAssetPanelHidden()
const isHidden = useStore(assetPanelStore, (state) => state.isAssetPanelHidden, {
unsafeEnableTransition: true,
})
const isExpanded = useIsAssetPanelExpanded()
const panelWidth = isExpanded ? ASSET_PANEL_TOTAL_WIDTH : ASSET_SIDEBAR_COLLAPSED_WIDTH
const isVisible = !isHidden
const compensationWidth = isVisible ? panelWidth : 0
return (
<AnimatePresence initial={!isVisible} mode="sync">
// We use hex color here to avoid muliplying bg colors due to opacity.
<div className="relative flex h-full flex-col">
<div style={{ width: compensationWidth, height: 0 }} />
{isVisible && (
<motion.div
data-testid="asset-panel"
initial="initial"
animate="animate"
exit="exit"
custom={panelWidth}
variants={{
initial: { opacity: 0, width: 0 },
animate: (width: number) => ({ opacity: 1, width }),
exit: { opacity: 0, width: 0 },
}}
transition={DEFAULT_TRANSITION_OPTIONS}
className="relative flex h-full flex-col shadow-softer clip-path-left-shadow"
onClick={(event) => {
// Prevent deselecting Assets Table rows.
event.stopPropagation()
}}
>
<InternalAssetPanelTabs {...props} />
</motion.div>
<div
className="absolute bottom-0 right-0 top-0 bg-dashboard shadow-softer clip-path-left-shadow"
style={{ width: ASSET_SIDEBAR_COLLAPSED_WIDTH }}
/>
)}
</AnimatePresence>
<AnimatePresence initial={!isVisible}>
{isVisible && (
<motion.div
style={{ width: panelWidth }}
data-testid="asset-panel"
initial={{ opacity: 0, x: ASSET_SIDEBAR_COLLAPSED_WIDTH }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: ASSET_SIDEBAR_COLLAPSED_WIDTH }}
className="absolute bottom-0 right-0 top-0 flex flex-col bg-background-hex"
onClick={(event) => {
// Prevent deselecting Assets Table rows.
event.stopPropagation()
}}
>
<InternalAssetPanelTabs panelWidth={panelWidth} {...props} />
</motion.div>
)}
</AnimatePresence>
</div>
)
}
/**
* The internal implementation of the Asset Panel Tabs.
*/
const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(props: AssetPanelProps) {
const { category } = props
const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(
props: AssetPanelProps & { panelWidth: number },
) {
const { category, panelWidth } = props
const { item, spotlightOn, path } = useAssetPanelProps()
const itemId = useStore(assetPanelStore, (state) => state.assetPanelProps.item?.id, {
unsafeEnableTransition: true,
})
const selectedTab = useAssetPanelSelectedTab()
const setSelectedTab = useSetAssetPanelSelectedTab()
const isHidden = useIsAssetPanelHidden()
const selectedTab = useStore(assetPanelStore, (state) => state.selectedTab, {
unsafeEnableTransition: true,
})
const setSelectedTab = useStore(assetPanelStore, (state) => state.setSelectedTab, {
unsafeEnableTransition: true,
})
const isHidden = useStore(assetPanelStore, (state) => state.isAssetPanelHidden, {
unsafeEnableTransition: true,
})
const isReadonly = category.type === 'trash'
@ -156,9 +127,12 @@ const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(props: Asset
const backend = useBackend(category)
const getTranslation = useEventCallback(() => ASSET_SIDEBAR_COLLAPSED_WIDTH)
return (
<AssetPanelTabs
className="h-full"
style={{ width: panelWidth }}
orientation="vertical"
selectedKey={selectedTab}
defaultSelectedKey={selectedTab}
@ -181,56 +155,59 @@ const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(props: Asset
>
<AnimatePresence initial={!isExpanded} mode="sync">
{isExpanded && (
<div
className="min-h-full"
// We use clipPath to prevent the sidebar from being visible under tabs while expanding.
style={{ clipPath: `inset(0 ${ASSET_SIDEBAR_COLLAPSED_WIDTH}px 0 0)` }}
<motion.div
custom={ASSET_PANEL_WIDTH}
initial="initial"
animate="animate"
exit="exit"
variants={{
initial: { filter: 'blur(8px)', x: ASSET_PANEL_WIDTH },
animate: { filter: 'blur(0px)', x: 0 },
exit: { filter: 'blur(8px)', x: ASSET_PANEL_WIDTH },
}}
className="absolute bottom-0 top-0 h-full"
style={{
// to avoid blurry edges
clipPath: `inset(0 0 0 0)`,
width: ASSET_PANEL_WIDTH,
right: ASSET_SIDEBAR_COLLAPSED_WIDTH,
}}
>
<motion.div
initial={{ filter: 'blur(8px)' }}
animate={{ filter: 'blur(0px)' }}
exit={{ filter: 'blur(8px)' }}
transition={DEFAULT_TRANSITION_OPTIONS}
className="absolute left-0 top-0 h-full w-full bg-background"
style={{ width: ASSET_PANEL_WIDTH }}
>
<AssetPanelTabs.TabPanel id="settings" resetKeys={[item?.id]}>
<AssetProperties
backend={backend}
item={item}
isReadonly={isReadonly}
category={category}
spotlightOn={spotlightOn}
path={path}
/>
</AssetPanelTabs.TabPanel>
{/* We use hex color here to avoid muliplying bg colors due to opacity. */}
<div className="flex h-full flex-col bg-background-hex">
<ErrorBoundary resetKeys={[itemId]}>
<AssetPanelTabs.TabPanel id="settings">
<AssetProperties backend={backend} isReadonly={isReadonly} category={category} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="versions" resetKeys={[item?.id]}>
<AssetVersions backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="versions">
<AssetVersions backend={backend} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="sessions" resetKeys={[item?.id]}>
<AssetProjectSessions backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="sessions">
<AssetProjectSessions backend={backend} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="docs" resetKeys={[item?.id]}>
<AssetDocs backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
</motion.div>
</div>
<AssetPanelTabs.TabPanel id="docs">
<AssetDocs backend={backend} />
</AssetPanelTabs.TabPanel>
</ErrorBoundary>
</div>
</motion.div>
)}
</AnimatePresence>
<div
className="absolute bottom-0 right-0 top-0 pt-2.5"
className="absolute bottom-0 right-0 top-0 bg-dashboard pt-2.5"
style={{ width: ASSET_SIDEBAR_COLLAPSED_WIDTH }}
>
<AssetPanelToggle
showWhen="expanded"
className="flex aspect-square w-full items-center justify-center"
getTranslation={getTranslation}
/>
<AssetPanelTabs.TabList>
<AssetPanelTabs.TabList className="">
<AssetPanelTabs.Tab
id="settings"
icon={inspectIcon}

View File

@ -0,0 +1,250 @@
/**
* @file
* The state of the asset panel. Can be used to control the asset panel's visibility,
* selected tab, and other properties from outside the component.
*/
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import type Backend from '#/services/Backend'
import type { AnyAsset } from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import * as zustand from '#/utilities/zustand'
import { startTransition } from 'react'
import { z } from 'zod'
import type { AssetPropertiesSpotlight } from '../AssetProperties'
import { ASSET_PANEL_TABS, type AssetPanelTab } from './types'
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly isAssetPanelVisible: boolean
readonly isAssetPanelHidden: boolean
readonly assetPanelTab: AssetPanelTab
readonly assetPanelWidth: number
}
}
const ASSET_PANEL_TAB_SCHEMA = z.enum(ASSET_PANEL_TABS)
LocalStorage.register({
assetPanelTab: { schema: ASSET_PANEL_TAB_SCHEMA },
assetPanelWidth: { schema: z.number().int() },
isAssetPanelHidden: { schema: z.boolean() },
isAssetPanelVisible: { schema: z.boolean() },
})
/** The state of the asset panel. */
export interface AssetPanelState {
readonly selectedTab: AssetPanelTab
readonly setSelectedTab: (tab: AssetPanelTab) => void
readonly isAssetPanelPermanentlyVisible: boolean
readonly isAssetPanelExpanded: boolean
readonly setIsAssetPanelExpanded: (isAssetPanelExpanded: boolean) => void
readonly setIsAssetPanelPermanentlyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
readonly toggleIsAssetPanelPermanentlyVisible: () => void
readonly isAssetPanelTemporarilyVisible: boolean
readonly setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
readonly assetPanelProps: AssetPanelContextProps
readonly setAssetPanelProps: (assetPanelProps: Partial<AssetPanelContextProps>) => void
readonly isAssetPanelHidden: boolean
readonly setIsAssetPanelHidden: (isAssetPanelHidden: boolean) => void
}
export const assetPanelStore = zustand.createStore<AssetPanelState>((set, get) => {
const localStorage = LocalStorage.getInstance()
return {
selectedTab: localStorage.get('assetPanelTab') ?? 'settings',
setSelectedTab: (tab) => {
set({ selectedTab: tab })
localStorage.set('assetPanelTab', tab)
},
isAssetPanelPermanentlyVisible: false,
toggleIsAssetPanelPermanentlyVisible: () => {
const state = get()
const next = !state.isAssetPanelPermanentlyVisible
state.setIsAssetPanelPermanentlyVisible(next)
},
setIsAssetPanelPermanentlyVisible: (isAssetPanelPermanentlyVisible) => {
if (get().isAssetPanelPermanentlyVisible !== isAssetPanelPermanentlyVisible) {
set({ isAssetPanelPermanentlyVisible })
localStorage.set('isAssetPanelVisible', isAssetPanelPermanentlyVisible)
}
},
isAssetPanelExpanded: false,
setIsAssetPanelExpanded: (isAssetPanelExpanded) => {
const state = get()
if (state.isAssetPanelPermanentlyVisible !== isAssetPanelExpanded) {
state.setIsAssetPanelPermanentlyVisible(isAssetPanelExpanded)
state.setIsAssetPanelTemporarilyVisible(false)
}
if (state.isAssetPanelHidden && isAssetPanelExpanded) {
state.setIsAssetPanelHidden(false)
}
},
isAssetPanelTemporarilyVisible: false,
setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible) => {
const state = get()
if (state.isAssetPanelHidden && isAssetPanelTemporarilyVisible) {
state.setIsAssetPanelHidden(false)
}
if (state.isAssetPanelTemporarilyVisible !== isAssetPanelTemporarilyVisible) {
set({ isAssetPanelTemporarilyVisible })
}
},
assetPanelProps: {
selectedTab: localStorage.get('assetPanelTab') ?? 'settings',
backend: null,
item: null,
spotlightOn: null,
path: null,
},
setAssetPanelProps: (assetPanelProps) => {
const current = get().assetPanelProps
if (current !== assetPanelProps) {
set({ assetPanelProps: { ...current, ...assetPanelProps } })
}
},
isAssetPanelHidden: localStorage.get('isAssetPanelHidden') ?? false,
setIsAssetPanelHidden: (isAssetPanelHidden) => {
const state = get()
if (state.isAssetPanelHidden !== isAssetPanelHidden) {
set({ isAssetPanelHidden })
localStorage.set('isAssetPanelHidden', isAssetPanelHidden)
}
},
}
})
/** Props supplied by the row. */
export interface AssetPanelContextProps {
readonly backend: Backend | null
readonly selectedTab: AssetPanelTab
readonly item: AnyAsset | null
readonly path: string | null
readonly spotlightOn: AssetPropertiesSpotlight | null
}
/** Whether the Asset Panel is toggled on. */
export function useIsAssetPanelPermanentlyVisible() {
return zustand.useStore(assetPanelStore, (state) => state.isAssetPanelPermanentlyVisible, {
unsafeEnableTransition: true,
})
}
/** A function to set whether the Asset Panel is toggled on. */
export function useSetIsAssetPanelPermanentlyVisible() {
return zustand.useStore(assetPanelStore, (state) => state.setIsAssetPanelPermanentlyVisible, {
unsafeEnableTransition: true,
})
}
/** Whether the Asset Panel is currently visible (e.g. for editing a Datalink). */
export function useIsAssetPanelTemporarilyVisible() {
return zustand.useStore(assetPanelStore, (state) => state.isAssetPanelTemporarilyVisible, {
unsafeEnableTransition: true,
})
}
/** A function to set whether the Asset Panel is currently visible (e.g. for editing a Datalink). */
export function useSetIsAssetPanelTemporarilyVisible() {
return zustand.useStore(assetPanelStore, (state) => state.setIsAssetPanelTemporarilyVisible, {
unsafeEnableTransition: true,
})
}
/** Whether the Asset Panel is currently visible, either temporarily or permanently. */
export function useIsAssetPanelVisible() {
const isAssetPanelPermanentlyVisible = useIsAssetPanelPermanentlyVisible()
const isAssetPanelTemporarilyVisible = useIsAssetPanelTemporarilyVisible()
return isAssetPanelPermanentlyVisible || isAssetPanelTemporarilyVisible
}
/** Whether the Asset Panel is expanded. */
export function useIsAssetPanelExpanded() {
return zustand.useStore(
assetPanelStore,
({ isAssetPanelPermanentlyVisible, isAssetPanelTemporarilyVisible }) =>
isAssetPanelPermanentlyVisible || isAssetPanelTemporarilyVisible,
{ unsafeEnableTransition: true },
)
}
/** A function to set whether the Asset Panel is expanded. */
export function useSetIsAssetPanelExpanded() {
return zustand.useStore(assetPanelStore, (state) => state.setIsAssetPanelExpanded, {
unsafeEnableTransition: true,
})
}
/** Props for the Asset Panel. */
export function useAssetPanelProps() {
return zustand.useStore(assetPanelStore, (state) => state.assetPanelProps, {
unsafeEnableTransition: true,
areEqual: 'shallow',
})
}
/** The selected tab of the Asset Panel. */
export function useAssetPanelSelectedTab() {
return zustand.useStore(assetPanelStore, (state) => state.assetPanelProps.selectedTab, {
unsafeEnableTransition: true,
})
}
/** A function to set props for the Asset Panel. */
export function useSetAssetPanelProps() {
return zustand.useStore(assetPanelStore, (state) => state.setAssetPanelProps, {
unsafeEnableTransition: true,
})
}
/** A function to reset the Asset Panel props to their default values. */
export function useResetAssetPanelProps() {
return useEventCallback(() => {
const current = assetPanelStore.getState().assetPanelProps
if (current.item != null) {
assetPanelStore.setState({
assetPanelProps: {
selectedTab: current.selectedTab,
backend: null,
item: null,
spotlightOn: null,
path: null,
},
})
}
})
}
/** A function to set the selected tab of the Asset Panel. */
export function useSetAssetPanelSelectedTab() {
return useEventCallback((selectedTab: AssetPanelContextProps['selectedTab']) => {
startTransition(() => {
const current = assetPanelStore.getState().assetPanelProps
if (current.selectedTab !== selectedTab) {
assetPanelStore.setState({
assetPanelProps: { ...current, selectedTab },
})
}
})
})
}
/** Whether the Asset Panel is hidden. */
export function useIsAssetPanelHidden() {
return zustand.useStore(assetPanelStore, (state) => state.isAssetPanelHidden, {
unsafeEnableTransition: true,
})
}
/** A function to set whether the Asset Panel is hidden. */
export function useSetIsAssetPanelHidden() {
return zustand.useStore(assetPanelStore, (state) => state.setIsAssetPanelHidden, {
unsafeEnableTransition: true,
})
}

View File

@ -1,13 +1,13 @@
/** @file Tabs for the asset panel. Contains the visual state for the tabs and animations. */
import { AnimatedBackground } from '#/components/AnimatedBackground'
import type { TabListProps, TabPanelProps, TabProps } from '#/components/aria'
import type { TabListProps, TabPanelProps, TabPanelRenderProps, TabProps } from '#/components/aria'
import { Tab, TabList, TabPanel, Tabs, type TabsProps } from '#/components/aria'
import { useVisualTooltip } from '#/components/AriaComponents'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { Suspense } from '#/components/Suspense'
import SvgMask from '#/components/SvgMask'
import { AnimatePresence, motion } from 'framer-motion'
import { memo, useRef } from 'react'
import type { ReactNode } from 'react'
import { memo, useCallback, useRef } from 'react'
/** Display a set of tabs. */
export function AssetPanelTabs(props: TabsProps) {
@ -36,9 +36,9 @@ export interface AssetPanelTabProps extends TabProps {
const UNDERLAY_ELEMENT = (
<>
<div className="h-full w-full rounded-r-2xl bg-background" />
<div className="absolute -top-5 left-0 aspect-square w-5 [background:radial-gradient(circle_at_100%_0%,_transparent_70%,_var(--color-background)_70%)]" />
<div className="absolute -bottom-5 left-0 aspect-square w-5 [background:radial-gradient(circle_at_100%_100%,_transparent_70%,_var(--color-background)_70%)]" />
<div className="h-full w-full rounded-r-2xl bg-background-hex" />
<div className="absolute -top-5 left-0 aspect-square w-5 [background:radial-gradient(circle_at_100%_0%,_transparent_70%,_var(--color-background-hex)_70%)]" />
<div className="absolute -bottom-5 left-0 aspect-square w-5 [background:radial-gradient(circle_at_100%_100%,_transparent_70%,_var(--color-background-hex)_70%)]" />
</>
)
@ -83,7 +83,7 @@ export const AssetPanelTab = memo(function AssetPanelTab(props: AssetPanelTabPro
variants={{ active: { opacity: 1 }, inactive: { opacity: 0 } }}
initial="inactive"
animate={!isActive && isHovered ? 'active' : 'inactive'}
className="absolute inset-x-1.5 inset-y-1.5 -z-1 rounded-full bg-invert transition-colors duration-300"
className="absolute inset-x-1.5 inset-y-1.5 rounded-full bg-invert transition-colors duration-300"
/>
<div
@ -106,44 +106,48 @@ export const AssetPanelTab = memo(function AssetPanelTab(props: AssetPanelTabPro
/** Props for a {@link AssetPanelTabPanel}. */
export interface AssetPanelTabPanelProps extends TabPanelProps {
readonly resetKeys?: unknown[]
readonly children: ReactNode | ((renderProps: TabPanelRenderProps) => ReactNode)
}
const SUSPENSE_LOADER_PROPS = { className: 'my-auto' }
/** Display a tab panel. */
export function AssetPanelTabPanel(props: AssetPanelTabPanelProps) {
const { children, id = '', resetKeys = [] } = props
export const AssetPanelTabPanel = memo(function AssetPanelTabPanel(props: AssetPanelTabPanelProps) {
const { children, id = '' } = props
const renderTabPanel = useCallback(
(renderProps: TabPanelRenderProps) => {
const isSelected = renderProps.state.selectionManager.isSelected(id)
return (
<AnimatePresence initial={!isSelected} mode="popLayout">
{isSelected && (
<motion.div
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
initial={{ x: 16, filter: 'blur(4px)', opacity: 0 }}
animate={{ x: 0, filter: 'blur(0px)', opacity: 1 }}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
exit={{ x: 16, filter: 'blur(4px)', opacity: 0 }}
className="flex h-full w-full flex-col overflow-y-auto scroll-offset-edge-3xl"
>
<Suspense loaderProps={SUSPENSE_LOADER_PROPS}>
<div className="pointer-events-auto flex h-fit min-h-full w-full shrink-0 px-4 py-5">
{typeof children === 'function' ? children(renderProps) : children}
</div>
</Suspense>
</motion.div>
)}
</AnimatePresence>
)
},
[id, children],
)
return (
<TabPanel className="contents" shouldForceMount {...props}>
{(renderProps) => {
const isSelected = renderProps.state.selectionManager.isSelected(id)
return (
<AnimatePresence initial={!isSelected} mode="popLayout">
{isSelected && (
<motion.div
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
initial={{ x: 16, filter: 'blur(4px)', opacity: 0 }}
animate={{ x: 0, filter: 'blur(0px)', opacity: 1 }}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
exit={{ x: 16, filter: 'blur(4px)', opacity: 0 }}
className="flex h-full w-full flex-col overflow-y-auto scroll-offset-edge-3xl"
>
<Suspense loaderProps={{ className: 'my-auto' }}>
<ErrorBoundary resetKeys={[renderProps.state.selectedItem, ...resetKeys]}>
<div className="pointer-events-auto flex h-fit min-h-full w-full shrink-0 px-4 py-5">
{typeof children === 'function' ? children(renderProps) : children}
</div>
</ErrorBoundary>
</Suspense>
</motion.div>
)}
</AnimatePresence>
)
}}
<TabPanel className="contents" shouldForceMount id={id}>
{renderTabPanel}
</TabPanel>
)
}
})
AssetPanelTabs.Tab = AssetPanelTab
AssetPanelTabs.TabPanel = AssetPanelTabPanel

View File

@ -5,11 +5,12 @@
import RightPanelIcon from '#/assets/right_panel.svg'
import { Button } from '#/components/AriaComponents'
import { useIsAssetPanelHidden, useSetIsAssetPanelHidden } from '#/providers/DriveProvider'
import { useText } from '#/providers/TextProvider'
import type { Spring } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import { memo } from 'react'
import { useIsAssetPanelHidden, useSetIsAssetPanelHidden } from '../AssetPanelState'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
/**
* Props for a {@link AssetPanelToggle}.
@ -17,26 +18,20 @@ import { memo } from 'react'
export interface AssetPanelToggleProps {
readonly className?: string
readonly showWhen?: 'collapsed' | 'expanded'
}
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
readonly getTranslation?: () => number
}
const COLLAPSED_X_TRANSLATION = 16
const EXPANDED_X_TRANSLATION = -16
/**
* Toggle for opening the asset panel.
*/
export const AssetPanelToggle = memo(function AssetPanelToggle(props: AssetPanelToggleProps) {
const { className, showWhen = 'collapsed' } = props
const {
className,
showWhen = 'collapsed',
getTranslation = () => COLLAPSED_X_TRANSLATION,
} = props
const { getText } = useText()
const isAssetPanelHidden = useIsAssetPanelHidden()
@ -44,6 +39,10 @@ export const AssetPanelToggle = memo(function AssetPanelToggle(props: AssetPanel
const canDisplay = showWhen === 'collapsed' ? isAssetPanelHidden : !isAssetPanelHidden
const toggleAssetPanel = useEventCallback(() => {
setIsAssetPanelHidden(!isAssetPanelHidden)
})
return (
<AnimatePresence initial={!canDisplay} mode="sync">
{canDisplay && (
@ -53,15 +52,14 @@ export const AssetPanelToggle = memo(function AssetPanelToggle(props: AssetPanel
initial={{
opacity: 0,
filter: 'blur(4px)',
x: showWhen === 'collapsed' ? COLLAPSED_X_TRANSLATION : EXPANDED_X_TRANSLATION,
x: showWhen === 'collapsed' ? getTranslation() : -getTranslation(),
}}
animate={{ opacity: 1, filter: 'blur(0px)', x: 0 }}
exit={{
opacity: 0,
filter: 'blur(4px)',
x: showWhen === 'collapsed' ? COLLAPSED_X_TRANSLATION : EXPANDED_X_TRANSLATION,
x: showWhen === 'collapsed' ? getTranslation() : -getTranslation(),
}}
transition={DEFAULT_TRANSITION_OPTIONS}
>
<Button
size="medium"
@ -69,9 +67,7 @@ export const AssetPanelToggle = memo(function AssetPanelToggle(props: AssetPanel
isActive={!isAssetPanelHidden}
icon={RightPanelIcon}
aria-label={getText('openAssetPanel')}
onPress={() => {
setIsAssetPanelHidden(!isAssetPanelHidden)
}}
onPress={toggleAssetPanel}
/>
</motion.div>
)}

View File

@ -3,4 +3,5 @@
* Barrels for the `AssetPanel` component.
*/
export * from './AssetPanel'
export * from './AssetPanelState'
export { AssetPanelToggle, type AssetPanelToggleProps } from './components/AssetPanelToggle'

View File

@ -0,0 +1,10 @@
/**
* @file
* Types for the {@link import('./AssetPanel').AssetPanel} component.
*/
/** Determines the content of the {@link import('./AssetPanel').AssetPanel}. */
export const ASSET_PANEL_TABS = ['settings', 'versions', 'sessions', 'schedules', 'docs'] as const
/** Determines the content of the {@link import('./AssetPanel').AssetPanel}. */
export type AssetPanelTab = (typeof ASSET_PANEL_TABS)[number]

View File

@ -7,20 +7,25 @@ import type Backend from '#/services/Backend'
import { Result } from '#/components/Result'
import { useText } from '#/providers/TextProvider'
import { AssetType, BackendType, type AnyAsset, type ProjectAsset } from '#/services/Backend'
import { AssetType, BackendType, type ProjectAsset } from '#/services/Backend'
import { useStore } from '#/utilities/zustand'
import { assetPanelStore } from './AssetPanel'
/** Props for a {@link AssetProjectSessions}. */
export interface AssetProjectSessionsProps {
readonly backend: Backend
readonly item: AnyAsset | null
}
/** A list of previous versions of an asset. */
export default function AssetProjectSessions(props: AssetProjectSessionsProps) {
const { backend, item } = props
const { backend } = props
const { getText } = useText()
const { item } = useStore(assetPanelStore, (state) => ({ item: state.assetPanelProps.item }), {
unsafeEnableTransition: true,
})
if (backend.type === BackendType.local) {
return <Result status="info" centered title={getText('assetProjectSessions.localBackend')} />
}

View File

@ -19,15 +19,15 @@ import Label from '#/components/dashboard/Label'
import { Result } from '#/components/Result'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import { validateDatalink } from '#/data/datalinkValidator'
import { backendMutationOptions, useAssetStrict, useBackendQuery } from '#/hooks/backendHooks'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSpotlight } from '#/hooks/spotlightHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { assetPanelStore, useSetAssetPanelProps } from '#/layouts/AssetPanel/'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useDriveStore, useSetAssetPanelProps } from '#/providers/DriveProvider'
import { useFeatureFlags } from '#/providers/FeatureFlagsProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
@ -37,6 +37,7 @@ import { normalizePath } from '#/utilities/fileInfo'
import { mapNonNullish } from '#/utilities/nullable'
import * as permissions from '#/utilities/permissions'
import { tv } from '#/utilities/tailwindVariants'
import { useStore } from '../utilities/zustand'
const ASSET_PROPERTIES_VARIANTS = tv({
base: '',
@ -51,18 +52,25 @@ export type AssetPropertiesSpotlight = 'datalink' | 'description' | 'secret'
/** Props for an {@link AssetPropertiesProps}. */
export interface AssetPropertiesProps {
readonly backend: Backend
readonly item: AnyAsset | null
readonly path: string | null
readonly category: Category
readonly isReadonly?: boolean
readonly spotlightOn?: AssetPropertiesSpotlight | null
}
/**
* Display and modify the properties of an asset.
*/
export default function AssetProperties(props: AssetPropertiesProps) {
const { item, isReadonly = false, backend, category, spotlightOn = null, path } = props
const { isReadonly = false, backend, category } = props
const { item, spotlightOn, path } = useStore(
assetPanelStore,
(state) => ({
item: state.assetPanelProps.item,
spotlightOn: state.assetPanelProps.spotlightOn ?? null,
path: state.assetPanelProps.path,
}),
{ unsafeEnableTransition: true },
)
const { getText } = useText()
@ -86,8 +94,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
* Props for {@link AssetPropertiesInternal}.
*/
export interface AssetPropertiesInternalProps extends AssetPropertiesProps {
readonly item: NonNullable<AssetPropertiesProps['item']>
readonly path: NonNullable<AssetPropertiesProps['path']>
readonly item: AnyAsset
readonly path: string | null
readonly spotlightOn: AssetPropertiesSpotlight | null
}
/**
@ -97,18 +106,10 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
const { backend, item, category, spotlightOn, isReadonly = false, path: pathRaw } = props
const styles = ASSET_PROPERTIES_VARIANTS({})
const asset = useAssetStrict({
backend,
assetId: item.id,
parentId: item.parentId,
category,
})
const setAssetPanelProps = useSetAssetPanelProps()
const driveStore = useDriveStore()
const closeSpotlight = useEventCallback(() => {
const assetPanelProps = driveStore.getState().assetPanelProps
const assetPanelProps = assetPanelStore.getState().assetPanelProps
setAssetPanelProps({ ...assetPanelProps, spotlightOn: null })
})
const { user } = useFullUserSession()
@ -135,9 +136,9 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
backend,
'getDatalink',
// eslint-disable-next-line no-restricted-syntax
[asset.id as DatalinkId, asset.title],
[item.id as DatalinkId, item.title],
{
enabled: asset.type === AssetType.datalink,
enabled: item.type === AssetType.datalink,
...(featureFlags.enableAssetsTableBackgroundRefresh ?
{ refetchInterval: featureFlags.assetsTableBackgroundRefreshInterval }
: {}),
@ -157,21 +158,21 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
})
const labels = useBackendQuery(backend, 'listTags', []).data ?? []
const self = permissions.tryFindSelfPermission(user, asset.permissions)
const self = permissions.tryFindSelfPermission(user, item.permissions)
const ownsThisAsset = self?.permission === permissions.PermissionAction.own
const canEditThisAsset =
ownsThisAsset ||
self?.permission === permissions.PermissionAction.admin ||
self?.permission === permissions.PermissionAction.edit
const isSecret = asset.type === AssetType.secret
const isDatalink = asset.type === AssetType.datalink
const isSecret = item.type === AssetType.secret
const isDatalink = item.type === AssetType.datalink
const isCloud = backend.type === BackendType.remote
const pathComputed =
category.type === 'recent' || category.type === 'trash' ? null
: isCloud ? `${pathRaw}${item.type === AssetType.datalink ? '.datalink' : ''}`
: asset.type === AssetType.project ?
mapNonNullish(localBackend?.getProjectPath(asset.id) ?? null, normalizePath)
: normalizePath(extractTypeAndId(asset.id).id)
: item.type === AssetType.project ?
mapNonNullish(localBackend?.getProjectPath(item.id) ?? null, normalizePath)
: normalizePath(extractTypeAndId(item.id).id)
const path =
pathComputed == null ? null
: isCloud ? encodeURI(pathComputed)
@ -183,19 +184,19 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
)
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const displayedDescription =
editDescriptionMutation.variables?.[0] === asset.id ?
editDescriptionMutation.variables[1].description ?? asset.description
: asset.description
editDescriptionMutation.variables?.[0] === item.id ?
editDescriptionMutation.variables[1].description ?? item.description
: item.description
const editDescriptionForm = Form.useForm({
schema: (z) => z.object({ description: z.string() }),
defaultValues: { description: asset.description ?? '' },
defaultValues: { description: item.description ?? '' },
onSubmit: async ({ description }) => {
if (description !== asset.description) {
if (description !== item.description) {
await editDescriptionMutation.mutateAsync([
asset.id,
item.id,
{ parentDirectoryId: null, description },
asset.title,
item.title,
])
}
setIsEditingDescription(false)
@ -205,11 +206,11 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
React.useEffect(() => {
setIsEditingDescription(false)
}, [asset.id, setIsEditingDescription])
}, [item.id, setIsEditingDescription])
React.useEffect(() => {
resetEditDescriptionForm({ description: asset.description ?? '' })
}, [asset.description, resetEditDescriptionForm])
resetEditDescriptionForm({ description: item.description ?? '' })
}, [item.description, resetEditDescriptionForm])
const editDatalinkForm = Form.useForm({
schema: (z) => z.object({ datalink: z.custom((x) => validateDatalink(x)) }),
@ -219,8 +220,8 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
{
// The UI to submit this form is only visible if the asset is a datalink.
// eslint-disable-next-line no-restricted-syntax
datalinkId: asset.id as DatalinkId,
name: asset.title,
datalinkId: item.id as DatalinkId,
name: item.title,
parentDirectoryId: null,
value: datalink,
},
@ -320,7 +321,7 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
<Text className="text inline-block">{getText('labels')}</Text>
</td>
<td className="flex w-full gap-1 p-0">
{asset.labels?.map((value) => {
{item.labels?.map((value) => {
const label = labels.find((otherLabel) => otherLabel.value === value)
return (
label != null && (
@ -349,10 +350,10 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
noDialog
canReset
canCancel={false}
id={asset.id}
name={asset.title}
id={item.id}
name={item.title}
doCreate={async (name, value) => {
await updateSecretMutation.mutateAsync([asset.id, { value }, name])
await updateSecretMutation.mutateAsync([item.id, { value }, name])
}}
/>
</div>

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as detect from 'enso-common/src/detect'
import FindIcon from '#/assets/find.svg'
import { unsafeWriteValue } from '#/utilities/write'
import * as backendHooks from '#/hooks/backendHooks'
@ -17,16 +18,17 @@ import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import type Backend from '#/services/Backend'
import { useSuggestions } from '#/providers/DriveProvider'
import type { Label as BackendLabel } from '#/services/Backend'
import * as array from '#/utilities/array'
import AssetQuery from '#/utilities/AssetQuery'
import * as eventModule from '#/utilities/event'
import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { createStore, useStore } from '#/utilities/zustand'
import { AnimatePresence, motion } from 'framer-motion'
import { useEventCallback } from '../hooks/eventCallbackHooks'
// =============
// === Types ===
@ -49,6 +51,7 @@ enum QuerySource {
/** A suggested query. */
export interface Suggestion {
readonly key: string
readonly render: () => React.ReactNode
readonly addToQuery: (query: AssetQuery) => AssetQuery
readonly deleteFromQuery: (query: AssetQuery) => AssetQuery
@ -66,6 +69,25 @@ interface InternalTagsProps {
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
}
export const searchbarSuggestionsStore = createStore<{
readonly suggestions: readonly Suggestion[]
readonly setSuggestions: (suggestions: readonly Suggestion[]) => void
}>((set) => ({
suggestions: [],
setSuggestions: (suggestions) => {
set({ suggestions })
},
}))
/**
* Sets the suggestions.
*/
export function useSetSuggestions() {
return useStore(searchbarSuggestionsStore, (state) => state.setSuggestions, {
unsafeEnableTransition: true,
})
}
/** Tags (`name:`, `modified:`, etc.) */
function Tags(props: InternalTagsProps) {
const { isCloud, querySource, query, setQuery } = props
@ -102,7 +124,7 @@ function Tags(props: InternalTagsProps) {
size="xsmall"
className="min-w-12"
onPress={() => {
querySource.current = QuerySource.internal
unsafeWriteValue(querySource, 'current', QuerySource.internal)
setQuery(query.add({ [key]: [[]] }))
}}
>
@ -130,13 +152,18 @@ export interface AssetSearchBarProps {
/** A search bar containing a text input, and a list of suggestions. */
function AssetSearchBar(props: AssetSearchBarProps) {
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 rawSuggestions = useStore(searchbarSuggestionsStore, (state) => state.suggestions, {
unsafeEnableTransition: true,
})
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
const suggestionsRef = React.useRef(rawSuggestions)
const suggestionsRef = useSyncRef(suggestions)
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
const [areSuggestionsVisible, privateSetAreSuggestionsVisible] = React.useState(false)
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
@ -151,6 +178,13 @@ function AssetSearchBar(props: AssetSearchBarProps) {
})
})
React.useEffect(() => {
if (querySource.current !== QuerySource.tabbing) {
setSuggestions(rawSuggestions)
unsafeWriteValue(suggestionsRef, 'current', rawSuggestions)
}
}, [rawSuggestions, suggestionsRef])
React.useEffect(() => {
if (querySource.current !== QuerySource.tabbing) {
baseQuery.current = query
@ -172,23 +206,19 @@ function AssetSearchBar(props: AssetSearchBarProps) {
}
}, [query])
React.useEffect(() => {
if (querySource.current !== QuerySource.tabbing) {
setSuggestions(rawSuggestions)
suggestionsRef.current = rawSuggestions
}
}, [rawSuggestions])
const selectedIndexDeps = useSyncRef({ query, setQuery, suggestions })
React.useEffect(() => {
const deps = selectedIndexDeps.current
if (
querySource.current === QuerySource.internal ||
querySource.current === QuerySource.tabbing
) {
let newQuery = query
const suggestion = selectedIndex == null ? null : suggestions[selectedIndex]
let newQuery = deps.query
const suggestion = selectedIndex == null ? null : deps.suggestions[selectedIndex]
if (suggestion != null) {
newQuery = suggestion.addToQuery(baseQuery.current)
setQuery(newQuery)
deps.setQuery(newQuery)
}
searchRef.current?.focus()
const end = searchRef.current?.value.length ?? 0
@ -197,9 +227,7 @@ function AssetSearchBar(props: AssetSearchBarProps) {
searchRef.current.value = newQuery.toString()
}
}
// This effect MUST only run when `selectedIndex` changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex])
}, [selectedIndex, selectedIndexDeps])
React.useEffect(() => {
const onSearchKeyDown = (event: KeyboardEvent) => {
@ -270,7 +298,7 @@ function AssetSearchBar(props: AssetSearchBarProps) {
root?.removeEventListener('keydown', onSearchKeyDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [setQuery, modalRef, setAreSuggestionsVisible])
}, [setQuery, modalRef, setAreSuggestionsVisible, suggestionsRef])
// Reset `querySource` after all other effects have run.
React.useEffect(() => {
@ -283,6 +311,30 @@ function AssetSearchBar(props: AssetSearchBarProps) {
}
}, [query, setQuery])
const onSearchFieldKeyDown = useEventCallback((event: aria.KeyboardEvent) => {
event.continuePropagation()
})
const searchFieldOnChange = useEventCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (querySource.current !== QuerySource.internal) {
querySource.current = QuerySource.typing
setQuery(AssetQuery.fromString(event.target.value))
}
})
const searchInputOnKeyDown = useEventCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey
) {
// Clone the query to refresh results.
setQuery(query.clone())
}
})
return (
<FocusArea direction="horizontal">
{(innerProps) => (
@ -328,48 +380,15 @@ function AssetSearchBar(props: AssetSearchBarProps) {
src={FindIcon}
className="absolute left-2 top-[50%] z-1 mt-[1px] -translate-y-1/2 text-primary/40"
/>
<FocusRing placement="before">
<aria.SearchField
aria-label={getText('assetSearchFieldLabel')}
className="relative grow before:text before:absolute before:-inset-x-1 before:my-auto before:rounded-full before:transition-all"
value={query.query}
onKeyDown={(event) => {
event.continuePropagation()
}}
>
<aria.Input
type="search"
ref={searchRef}
size={1}
placeholder={
isCloud ?
detect.isOnMacOS() ?
getText('remoteBackendSearchPlaceholderMacOs')
: getText('remoteBackendSearchPlaceholder')
: getText('localBackendSearchPlaceholder')
}
className="focus-child peer text relative z-1 w-full bg-transparent placeholder-primary/40"
onChange={(event) => {
if (querySource.current !== QuerySource.internal) {
querySource.current = QuerySource.typing
setQuery(AssetQuery.fromString(event.target.value))
}
}}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey
) {
// Clone the query to refresh results.
setQuery(query.clone())
}
}}
/>
</aria.SearchField>
</FocusRing>
<AssetSearchBarInput
query={query}
isCloud={isCloud}
onSearchFieldKeyDown={onSearchFieldKeyDown}
searchRef={searchRef}
searchFieldOnChange={searchFieldOnChange}
searchInputOnKeyDown={searchInputOnKeyDown}
/>
</aria.Label>
</div>
)}
@ -377,6 +396,62 @@ function AssetSearchBar(props: AssetSearchBarProps) {
)
}
/** Props for a {@link AssetSearchBarInput}. */
interface AssetSearchBarInputProps {
readonly query: AssetQuery
readonly isCloud: boolean
readonly onSearchFieldKeyDown: (event: aria.KeyboardEvent) => void
readonly searchRef: React.RefObject<HTMLInputElement>
readonly searchFieldOnChange: (event: React.ChangeEvent<HTMLInputElement>) => void
readonly searchInputOnKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
}
/**
* Renders the search field.
*/
// eslint-disable-next-line no-restricted-syntax
const AssetSearchBarInput = React.memo(function AssetSearchBarInput(
props: AssetSearchBarInputProps,
) {
const {
query,
isCloud,
onSearchFieldKeyDown,
searchRef,
searchFieldOnChange,
searchInputOnKeyDown,
} = props
const { getText } = textProvider.useText()
return (
<>
<FocusRing placement="before">
<aria.SearchField
aria-label={getText('assetSearchFieldLabel')}
className="relative grow before:text before:absolute before:-inset-x-1 before:my-auto before:rounded-full before:transition-all"
value={query.query}
onKeyDown={onSearchFieldKeyDown}
>
<aria.Input
type="search"
ref={searchRef}
size={1}
placeholder={
isCloud ?
detect.isOnMacOS() ?
getText('remoteBackendSearchPlaceholderMacOs')
: getText('remoteBackendSearchPlaceholder')
: getText('localBackendSearchPlaceholder')
}
className="focus-child peer text relative z-1 w-full bg-transparent placeholder-primary/40"
onChange={searchFieldOnChange}
onKeyDown={searchInputOnKeyDown}
/>
</aria.SearchField>
</FocusRing>
</>
)
})
/**
* Props for a {@link AssetSearchBarPopover}.
*/
@ -416,12 +491,12 @@ function AssetSearchBarPopover(props: AssetSearchBarPopoverProps) {
return (
<>
<AnimatePresence>
<AnimatePresence mode="wait" custom={suggestions.length}>
{areSuggestionsVisible && (
<motion.div
initial={{ gridTemplateRows: '0fr', opacity: 0 }}
animate={{ gridTemplateRows: '1fr', opacity: 1 }}
exit={{ gridTemplateRows: '0fr', opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className={ariaComponents.DIALOG_BACKGROUND({
className:
'absolute left-0 right-0 top-0 z-1 grid w-full overflow-hidden rounded-default border-0.5 border-primary/20 -outline-offset-1 outline-primary',
@ -449,7 +524,7 @@ function AssetSearchBarPopover(props: AssetSearchBarPopoverProps) {
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
{suggestions.map((suggestion, index) => (
<SuggestionRenderer
key={index}
key={suggestion.key}
index={index}
selectedIndex={selectedIndex}
selectedIndices={selectedIndices}
@ -515,12 +590,12 @@ const SuggestionRenderer = React.memo(function SuggestionRenderer(props: Suggest
}
}}
className={tailwindMerge.twMerge(
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
'flex w-full cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
selectedIndices.has(index) && 'bg-primary/10',
index === selectedIndex && 'bg-selected-frame',
)}
onPress={(event) => {
querySource.current = QuerySource.internal
unsafeWriteValue(querySource, 'current', QuerySource.internal)
setQuery(
selectedIndices.has(index) ?
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
@ -566,6 +641,25 @@ const Labels = React.memo(function Labels(props: LabelsProps) {
const labels = backendHooks.useBackendQuery(backend, 'listTags', []).data ?? []
const labelOnPress = useEventCallback(
(event: aria.PressEvent | React.MouseEvent<HTMLButtonElement>, label?: BackendLabel) => {
if (label == null) {
return
}
unsafeWriteValue(querySource, 'current', QuerySource.internal)
setQuery((oldQuery) => {
const newQuery = oldQuery.withToggled(
'labels',
'negativeLabels',
label.value,
event.shiftKey,
)
unsafeWriteValue(baseQuery, 'current', newQuery)
return newQuery
})
},
)
return (
<>
{isCloud && labels.length !== 0 && (
@ -580,23 +674,12 @@ const Labels = React.memo(function Labels(props: LabelsProps) {
<Label
key={label.id}
color={label.color}
label={label}
active={
negated || query.labels.some((term) => array.shallowEqual(term, [label.value]))
}
negated={negated}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery((oldQuery) => {
const newQuery = oldQuery.withToggled(
'labels',
'negativeLabels',
label.value,
event.shiftKey,
)
baseQuery.current = newQuery
return newQuery
})
}}
onPress={labelOnPress}
>
{label.value}
</Label>

View File

@ -1,21 +1,22 @@
/** @file A list of previous versions of an asset. */
import * as React from 'react'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import * as textProvider from '#/providers/TextProvider'
import AssetVersion from '#/layouts/AssetVersions/AssetVersion'
import type Backend from '#/services/Backend'
import * as backendService from '#/services/Backend'
import * as uniqueString from 'enso-common/src/utilities/uniqueString'
import { Result } from '#/components/Result'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import AssetVersion from '#/layouts/AssetVersions/AssetVersion'
import * as textProvider from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import type { AnyAsset } from '#/services/Backend'
import * as backendService from '#/services/Backend'
import * as dateTime from '#/utilities/dateTime'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import * as uniqueString from 'enso-common/src/utilities/uniqueString'
import { assetVersionsQueryOptions } from './useAssetVersions.ts'
import { noop } from '#/utilities/functions'
import { useStore } from '#/utilities/zustand'
import { assetPanelStore } from '../AssetPanel/AssetPanelState'
import { assetVersionsQueryOptions } from './useAssetVersions'
// ==============================
// === AddNewVersionVariables ===
@ -34,14 +35,17 @@ interface AddNewVersionVariables {
/** Props for a {@link AssetVersions}. */
export interface AssetVersionsProps {
readonly backend: Backend
readonly item: AnyAsset | null
}
/**
* Display a list of previous versions of an asset.
*/
export default function AssetVersions(props: AssetVersionsProps) {
const { item, backend } = props
const { backend } = props
const { item } = useStore(assetPanelStore, (state) => ({ item: state.assetPanelProps.item }), {
unsafeEnableTransition: true,
})
const { getText } = textProvider.useText()
@ -82,13 +86,7 @@ function AssetVersionsInternal(props: AssetVersionsInternalProps) {
readonly backendService.S3ObjectVersion[]
>([])
const versionsQuery = useSuspenseQuery(
assetVersionsQueryOptions({
assetId: item.id,
backend,
onError: (backendError) => toastAndLog('listVersionsError', backendError),
}),
)
const versionsQuery = useSuspenseQuery(assetVersionsQueryOptions({ assetId: item.id, backend }))
const latestVersion = versionsQuery.data.find((version) => version.isLatest)
@ -140,7 +138,7 @@ function AssetVersionsInternal(props: AssetVersionsInternalProps) {
item={item}
backend={backend}
latestVersion={latestVersion}
doRestore={() => {}}
doRestore={noop}
/>
)),
...versionsQuery.data.map((version, i) => (

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import * as zustand from 'zustand'
import type * as assetEvent from '#/events/assetEvent'
import type * as assetListEvent from '#/events/assetListEvent'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
// ======================
// === EventListStore ===
@ -115,24 +116,21 @@ export function useAssetEventListener(
callback: (event: assetEvent.AssetEvent) => Promise<void> | void,
initialEvents?: readonly assetEvent.AssetEvent[] | null,
) {
const callbackRef = React.useRef(callback)
callbackRef.current = callback
const stableCallback = useEventCallback(callback)
const store = useEventList()
const seen = React.useRef(new WeakSet())
const initialEventsRef = React.useRef(initialEvents)
let alreadyRun = false
React.useEffect(() => {
const events = initialEventsRef.current
if (events && !alreadyRun) {
// Event handlers are not idempotent and MUST NOT be handled twice.
// eslint-disable-next-line react-hooks/exhaustive-deps
alreadyRun = true
if (events) {
for (const event of events) {
void callbackRef.current(event)
void stableCallback(event)
}
}
}, [])
// Clear the events list to avoid handling them twice in dev mode.
initialEventsRef.current = undefined
}, [stableCallback])
React.useEffect(
() =>
@ -141,12 +139,12 @@ export function useAssetEventListener(
for (const event of state.assetEvents) {
if (!seen.current.has(event)) {
seen.current.add(event)
void callbackRef.current(event)
void stableCallback(event)
}
}
}
}),
[store],
[stableCallback, store],
)
}
@ -159,24 +157,21 @@ export function useAssetListEventListener(
callback: (event: assetListEvent.AssetListEvent) => Promise<void> | void,
initialEvents?: readonly assetListEvent.AssetListEvent[] | null,
) {
const callbackRef = React.useRef(callback)
callbackRef.current = callback
const stableCallback = useEventCallback(callback)
const store = useEventList()
const seen = React.useRef(new WeakSet())
const initialEventsRef = React.useRef(initialEvents)
let alreadyRun = false
React.useEffect(() => {
const events = initialEventsRef.current
if (events && !alreadyRun) {
// Event handlers are not idempotent and MUST NOT be handled twice.
// eslint-disable-next-line react-hooks/exhaustive-deps
alreadyRun = true
if (events) {
for (const event of events) {
void callbackRef.current(event)
void stableCallback(event)
}
}
}, [])
// Clear the events list to avoid handling them twice in dev mode.
initialEventsRef.current = undefined
}, [stableCallback])
React.useEffect(
() =>
@ -185,11 +180,11 @@ export function useAssetListEventListener(
for (const event of state.assetListEvents) {
if (!seen.current.has(event)) {
seen.current.add(event)
void callbackRef.current(event)
void stableCallback(event)
}
}
}
}),
[store],
[stableCallback, store],
)
}

View File

@ -0,0 +1,259 @@
/** @file A hook to return the asset tree. */
import { useMemo } from 'react'
import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
import {
assetIsDirectory,
createRootDirectoryAsset,
createSpecialEmptyAsset,
createSpecialErrorAsset,
createSpecialLoadingAsset,
type AnyAsset,
type DirectoryAsset,
type DirectoryId,
} from 'enso-common/src/services/Backend'
import { listDirectoryQueryOptions } from '#/hooks/backendHooks'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useBackend } from '#/providers/BackendProvider'
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import AssetTreeNode, { type AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
/** Return type of the query function for the `listDirectory` query. */
export type DirectoryQuery = readonly AnyAsset[] | undefined
/** Options for {@link useAssetTree}. */
export interface UseAssetTreeOptions {
readonly hidden: boolean
readonly category: Category
readonly rootDirectory: DirectoryAsset
readonly expandedDirectoryIds: readonly DirectoryId[]
}
/** A hook to return the asset tree. */
export function useAssetTree(options: UseAssetTreeOptions) {
const { hidden, category, rootDirectory, expandedDirectoryIds } = options
const { user } = useFullUserSession()
const backend = useBackend(category)
const enableAssetsTableBackgroundRefresh = useFeatureFlag('enableAssetsTableBackgroundRefresh')
const assetsTableBackgroundRefreshInterval = useFeatureFlag(
'assetsTableBackgroundRefreshInterval',
)
const directories = useQueries({
// We query only expanded directories, as we don't want to load the data for directories that are not visible.
queries: expandedDirectoryIds.map((directoryId) => ({
...listDirectoryQueryOptions({
backend,
parentId: directoryId,
category,
}),
enabled: !hidden,
})),
combine: (results) => {
const rootQuery = results[expandedDirectoryIds.indexOf(rootDirectory.id)]
return {
rootDirectory: {
isFetching: rootQuery?.isFetching ?? true,
isLoading: rootQuery?.isLoading ?? true,
isError: rootQuery?.isError ?? false,
error: rootQuery?.error,
data: rootQuery?.data,
},
directories: new Map(
results.map((res, i) => [
expandedDirectoryIds[i],
{
isFetching: res.isFetching,
isLoading: res.isLoading,
isError: res.isError,
error: res.error,
data: res.data,
},
]),
),
}
},
})
const queryClient = useQueryClient()
// We use a different query to refetch the directory data in the background.
// This reduces the amount of rerenders by batching them together, so they happen less often.
useQuery(
useMemo(
() => ({
queryKey: [backend.type, 'refetchListDirectory'],
queryFn: async () => {
await queryClient.refetchQueries({ queryKey: [backend.type, 'listDirectory'] })
return null
},
refetchInterval:
enableAssetsTableBackgroundRefresh ? assetsTableBackgroundRefreshInterval : false,
refetchOnMount: 'always',
refetchIntervalInBackground: false,
refetchOnWindowFocus: true,
enabled: !hidden,
meta: { persist: false },
}),
[
backend.type,
enableAssetsTableBackgroundRefresh,
assetsTableBackgroundRefreshInterval,
hidden,
queryClient,
],
),
)
const rootDirectoryContent = directories.rootDirectory.data
const isError = directories.rootDirectory.isError
const isLoading = directories.rootDirectory.isLoading && !isError
const assetTree = useMemo(() => {
const rootPath = 'rootPath' in category ? category.rootPath : backend.rootPath(user)
// If the root directory is not loaded, then we cannot render the tree.
// Return null, and wait for the root directory to load.
if (rootDirectoryContent == null) {
return AssetTreeNode.fromAsset(
createRootDirectoryAsset(rootDirectory.id),
ROOT_PARENT_DIRECTORY_ID,
ROOT_PARENT_DIRECTORY_ID,
-1,
rootPath,
null,
)
} else if (isError) {
return AssetTreeNode.fromAsset(
createRootDirectoryAsset(rootDirectory.id),
ROOT_PARENT_DIRECTORY_ID,
ROOT_PARENT_DIRECTORY_ID,
-1,
rootPath,
null,
).with({
children: [
AssetTreeNode.fromAsset(
createSpecialErrorAsset(rootDirectory.id),
rootDirectory.id,
rootDirectory.id,
0,
'',
),
],
})
}
const rootId = rootDirectory.id
const children = rootDirectoryContent.map((content) => {
/**
* Recursively build assets tree. If a child is a directory, we search for its content
* in the loaded data. If it is loaded, we append that data to the asset node
* and do the same for the children.
*/
const withChildren = (node: AnyAssetTreeNode, depth: number) => {
const { item } = node
if (assetIsDirectory(item)) {
const childrenAssetsQuery = directories.directories.get(item.id)
const nestedChildren = childrenAssetsQuery?.data?.map((child) =>
AssetTreeNode.fromAsset(
child,
item.id,
item.id,
depth,
`${node.path}/${child.title}`,
null,
child.id,
),
)
if (childrenAssetsQuery == null || childrenAssetsQuery.isLoading) {
node = node.with({
children: [
AssetTreeNode.fromAsset(
createSpecialLoadingAsset(item.id),
item.id,
item.id,
depth,
'',
),
],
})
} else if (childrenAssetsQuery.isError) {
node = node.with({
children: [
AssetTreeNode.fromAsset(
createSpecialErrorAsset(item.id),
item.id,
item.id,
depth,
'',
),
],
})
} else if (nestedChildren?.length === 0) {
node = node.with({
children: [
AssetTreeNode.fromAsset(
createSpecialEmptyAsset(item.id),
item.id,
item.id,
depth,
'',
),
],
})
} else if (nestedChildren != null) {
node = node.with({
children: nestedChildren.map((child) => withChildren(child, depth + 1)),
})
}
}
return node
}
const node = AssetTreeNode.fromAsset(
content,
rootId,
rootId,
0,
`${rootPath}/${content.title}`,
null,
content.id,
)
const ret = withChildren(node, 1)
return ret
})
return new AssetTreeNode(
rootDirectory,
ROOT_PARENT_DIRECTORY_ID,
ROOT_PARENT_DIRECTORY_ID,
children,
-1,
rootPath,
null,
rootId,
)
}, [
backend,
category,
directories.directories,
isError,
rootDirectory,
rootDirectoryContent,
user,
])
return { isLoading, isError, assetTree } as const
}

View File

@ -0,0 +1,257 @@
/** @file A hook to return the items in the assets table. */
import { startTransition, useMemo } from 'react'
import type { AnyAsset, AssetId } from 'enso-common/src/services/Backend'
import { AssetType, getAssetPermissionName } from 'enso-common/src/services/Backend'
import { PermissionAction } from 'enso-common/src/utilities/permissions'
import type { SortableColumn } from '#/components/dashboard/column/columnUtils'
import { Column } from '#/components/dashboard/column/columnUtils'
import type { DirectoryId } from '#/services/ProjectManager'
import type AssetQuery from '#/utilities/AssetQuery'
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
import Visibility from '#/utilities/Visibility'
import { fileExtension } from '#/utilities/fileInfo'
import type { SortInfo } from '#/utilities/sorting'
import { SortDirection } from '#/utilities/sorting'
import { regexEscape } from '#/utilities/string'
import { createStore, useStore } from '#/utilities/zustand.ts'
import invariant from 'tiny-invariant'
/** Options for {@link useAssetsTableItems}. */
export interface UseAssetsTableOptions {
readonly assetTree: AnyAssetTreeNode
readonly query: AssetQuery
readonly sortInfo: SortInfo<SortableColumn> | null
readonly expandedDirectoryIds: readonly DirectoryId[]
}
export const ASSET_ITEMS_STORE = createStore<{
readonly items: AnyAsset[]
readonly setItems: (items: AnyAsset[]) => void
}>((set) => ({
items: [],
setItems: (items) => {
set({ items })
},
}))
/**
* Return the asset with the given id.
*/
export function useAsset(id: AssetId) {
return useStore(
ASSET_ITEMS_STORE,
(store) => store.items.find((item) => item.id === id) ?? null,
{
unsafeEnableTransition: true,
},
)
}
/**
* Return the asset with the given id, or throw an error if it is undefined.
*/
export function useAssetStrict(id: AssetId) {
const asset = useAsset(id)
invariant(
asset,
`Expected asset to be defined, but got undefined, Asset ID: ${JSON.stringify(id)}`,
)
return asset
}
/** A hook to return the items in the assets table. */
export function useAssetsTableItems(options: UseAssetsTableOptions) {
const { assetTree, sortInfo, query, expandedDirectoryIds } = options
const setAssetItems = useStore(ASSET_ITEMS_STORE, (store) => store.setItems)
const filter = useMemo(() => {
const globCache: Record<string, RegExp> = {}
if (/^\s*$/.test(query.query)) {
return null
} else {
return (node: AnyAssetTreeNode) => {
if (
node.item.type === AssetType.specialEmpty ||
node.item.type === AssetType.specialLoading
) {
return false
}
const assetType =
node.item.type === AssetType.directory ? 'folder'
: node.item.type === AssetType.datalink ? 'datalink'
: String(node.item.type)
const assetExtension =
node.item.type !== AssetType.file ? null : fileExtension(node.item.title).toLowerCase()
const assetModifiedAt = new Date(node.item.modifiedAt)
const nodeLabels: readonly string[] = node.item.labels ?? []
const lowercaseName = node.item.title.toLowerCase()
const lowercaseDescription = node.item.description?.toLowerCase() ?? ''
const owners =
node.item.permissions
?.filter((permission) => permission.permission === PermissionAction.own)
.map(getAssetPermissionName) ?? []
const globMatch = (glob: string, match: string) => {
const regex = (globCache[glob] =
globCache[glob] ??
new RegExp('^' + regexEscape(glob).replace(/(?:\\\*)+/g, '.*') + '$', 'i'))
return regex.test(match)
}
const isAbsent = (type: string) => {
switch (type) {
case 'label':
case 'labels': {
return nodeLabels.length === 0
}
case 'name': {
// Should never be true, but handle it just in case.
return lowercaseName === ''
}
case 'description': {
return lowercaseDescription === ''
}
case 'extension': {
// Should never be true, but handle it just in case.
return assetExtension === ''
}
}
// Things like `no:name` and `no:owner` are never true.
return false
}
const parseDate = (date: string) => {
const lowercase = date.toLowerCase()
switch (lowercase) {
case 'today': {
return new Date()
}
}
return new Date(date)
}
const matchesDate = (date: string) => {
const parsed = parseDate(date)
return (
parsed.getFullYear() === assetModifiedAt.getFullYear() &&
parsed.getMonth() === assetModifiedAt.getMonth() &&
parsed.getDate() === assetModifiedAt.getDate()
)
}
const isEmpty = (values: string[]) =>
values.length === 0 || (values.length === 1 && values[0] === '')
const filterTag = (
positive: string[][],
negative: string[][],
predicate: (value: string) => boolean,
) =>
positive.every((values) => isEmpty(values) || values.some(predicate)) &&
negative.every((values) => !values.some(predicate))
return (
filterTag(query.nos, query.negativeNos, (no) => isAbsent(no.toLowerCase())) &&
filterTag(query.keywords, query.negativeKeywords, (keyword) =>
lowercaseName.includes(keyword.toLowerCase()),
) &&
filterTag(query.names, query.negativeNames, (name) => globMatch(name, lowercaseName)) &&
filterTag(query.labels, query.negativeLabels, (label) =>
nodeLabels.some((assetLabel) => globMatch(label, assetLabel)),
) &&
filterTag(query.types, query.negativeTypes, (type) => type === assetType) &&
filterTag(
query.extensions,
query.negativeExtensions,
(extension) => extension.toLowerCase() === assetExtension,
) &&
filterTag(query.descriptions, query.negativeDescriptions, (description) =>
lowercaseDescription.includes(description.toLowerCase()),
) &&
filterTag(query.modifieds, query.negativeModifieds, matchesDate) &&
filterTag(query.owners, query.negativeOwners, (owner) =>
owners.some((assetOwner) => globMatch(owner, assetOwner)),
)
)
}
}
}, [query])
const visibilities = useMemo(() => {
const map = new Map<AssetId, Visibility>()
const processNode = (node: AnyAssetTreeNode) => {
let displayState = Visibility.hidden
const visible = filter?.(node) ?? true
for (const child of node.children ?? []) {
if (visible && child.item.type === AssetType.specialEmpty) {
map.set(child.key, Visibility.visible)
} else {
processNode(child)
}
if (map.get(child.key) !== Visibility.hidden) {
displayState = Visibility.faded
}
}
if (visible) {
displayState = Visibility.visible
}
map.set(node.key, displayState)
return displayState
}
processNode(assetTree)
return map
}, [assetTree, filter])
const displayItems = useMemo(() => {
if (sortInfo == null) {
const flatTree = assetTree.preorderTraversal((children) =>
children.filter((child) => expandedDirectoryIds.includes(child.directoryId)),
)
startTransition(() => {
setAssetItems(flatTree.map((item) => item.item))
})
return flatTree
} else {
const multiplier = sortInfo.direction === SortDirection.ascending ? 1 : -1
let compare: (a: AnyAssetTreeNode, b: AnyAssetTreeNode) => number
switch (sortInfo.field) {
case Column.name: {
compare = (a, b) => multiplier * a.item.title.localeCompare(b.item.title, 'en')
break
}
case Column.modified: {
compare = (a, b) => {
const aOrder = Number(new Date(a.item.modifiedAt))
const bOrder = Number(new Date(b.item.modifiedAt))
return multiplier * (aOrder - bOrder)
}
break
}
}
const flatTree = assetTree.preorderTraversal((tree) =>
[...tree].filter((child) => expandedDirectoryIds.includes(child.directoryId)).sort(compare),
)
startTransition(() => {
setAssetItems(flatTree.map((item) => item.item))
})
return flatTree
}
}, [sortInfo, assetTree, expandedDirectoryIds, setAssetItems])
const visibleItems = useMemo(
() => displayItems.filter((item) => visibilities.get(item.key) !== Visibility.hidden),
[displayItems, visibilities],
)
return { visibilities, displayItems, visibleItems } as const
}

View File

@ -0,0 +1,56 @@
/** @file A hook returning the root directory id and expanded directory ids. */
import { useMemo } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import { Path, createRootDirectoryAsset } from 'enso-common/src/services/Backend'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useBackend } from '#/providers/BackendProvider'
import { useExpandedDirectoryIds, useSetExpandedDirectoryIds } from '#/providers/DriveProvider'
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
/** Options for {@link useDirectoryIds}. */
export interface UseDirectoryIdsOptions {
readonly category: Category
}
/** A hook returning the root directory id and expanded directory ids. */
export function useDirectoryIds(options: UseDirectoryIdsOptions) {
const { category } = options
const backend = useBackend(category)
const { user } = useFullUserSession()
const organizationQuery = useSuspenseQuery({
queryKey: [backend.type, 'getOrganization'],
queryFn: () => backend.getOrganization(),
})
const organization = organizationQuery.data
/**
* The expanded directories in the asset tree.
* The root directory is not included as it might change when a user switches
* between items in sidebar and we don't want to reset the expanded state using `useEffect`.
*/
const privateExpandedDirectoryIds = useExpandedDirectoryIds()
const setExpandedDirectoryIds = useSetExpandedDirectoryIds()
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
const rootDirectoryId = useMemo(() => {
const localRootPath = localRootDirectory != null ? Path(localRootDirectory) : null
const id =
'homeDirectoryId' in category ?
category.homeDirectoryId
: backend.rootDirectoryId(user, organization, localRootPath)
invariant(id, 'Missing root directory')
return id
}, [category, backend, user, organization, localRootDirectory])
const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
const expandedDirectoryIds = useMemo(
() => [rootDirectoryId].concat(privateExpandedDirectoryIds),
[privateExpandedDirectoryIds, rootDirectoryId],
)
return { setExpandedDirectoryIds, rootDirectoryId, rootDirectory, expandedDirectoryIds } as const
}

View File

@ -173,7 +173,7 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
variant="custom"
tooltip={tooltip}
tooltipPlacement="right"
className={tailwindMerge.twMerge(
className={tailwindMerge.twJoin(
'min-w-0 flex-auto grow-0',
isCurrent && 'focus-default',
isDisabled && 'cursor-not-allowed hover:bg-transparent',
@ -182,13 +182,14 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
onPress={onPress}
>
<div
className={tailwindMerge.twMerge(
className={tailwindMerge.twJoin(
'group flex h-row min-w-0 flex-auto items-center gap-icon-with-text rounded-full px-button-x selectable',
isCurrent && 'disabled active',
!isCurrent && !isDisabled && 'hover:bg-selected-frame',
)}
>
<SvgMask src={icon} className={twMerge('shrink-0', iconClassName)} />
<ariaComponents.Text slot="description" truncate="1" className="flex-auto">
{label}
</ariaComponents.Text>
@ -224,7 +225,7 @@ export interface CategorySwitcherProps {
}
/** A switcher to choose the currently visible assets table categoryModule.categoryType. */
export default function CategorySwitcher(props: CategorySwitcherProps) {
function CategorySwitcher(props: CategorySwitcherProps) {
const { category, setCategory } = props
const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText()
@ -481,3 +482,5 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
</div>
)
}
export default React.memo(CategorySwitcher)

View File

@ -21,6 +21,7 @@ import * as ariaComponents from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
import Twemoji from '#/components/Twemoji'
import { useSyncRef } from '#/hooks/syncRefHooks'
import * as dateTime from '#/utilities/dateTime'
import * as newtype from '#/utilities/newtype'
import * as object from '#/utilities/object'
@ -471,18 +472,17 @@ export default function Chat(props: ChatProps) {
}
}, [isOpen, endpoint])
const autoScrollDeps = useSyncRef({ isAtBottom, isAtTop, messagesHeightBeforeMessageHistory })
React.useLayoutEffect(() => {
const deps = autoScrollDeps.current
const element = messagesRef.current
if (element != null && isAtTop && messagesHeightBeforeMessageHistory != null) {
element.scrollTop = element.scrollHeight - messagesHeightBeforeMessageHistory
if (element != null && deps.isAtTop && deps.messagesHeightBeforeMessageHistory != null) {
element.scrollTop = element.scrollHeight - deps.messagesHeightBeforeMessageHistory
setMessagesHeightBeforeMessageHistory(null)
} else if (element != null && isAtBottom) {
} else if (element != null && deps.isAtBottom) {
element.scrollTop = element.scrollHeight - element.clientHeight
}
// Auto-scroll MUST only happen when the message list changes.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages])
}, [messages, autoScrollDeps])
const sendMessage = React.useCallback(
(message: chat.ChatClientMessageData) => {

View File

@ -3,10 +3,6 @@ import { useText } from '#/providers/TextProvider'
import SettingsAriaInput from './AriaInput'
import type { SettingsContext, SettingsInputData } from './data'
// =====================
// === SettingsInput ===
// =====================
/** Props for a {@link SettingsInput}. */
export interface SettingsInputProps<T extends Record<keyof T, string>> {
readonly context: SettingsContext

View File

@ -186,51 +186,56 @@ function TwoFa() {
}),
})
const { field } = Form.useField({ name: 'display' })
return (
<>
<div className="flex w-full flex-col gap-4">
<Selector name="display" items={['qr', 'text']} aria-label={getText('display')} />
{field.value === 'qr' && (
<>
<Alert variant="neutral" icon={ShieldCheck}>
<Text.Group>
<Text variant="subtitle" weight="bold">
{getText('scanQR')}
</Text>
<Form.FieldValue name="display">
{(display) =>
display === 'qr' && (
<>
<Alert key="alert" variant="neutral" icon={ShieldCheck}>
<Text.Group>
<Text variant="subtitle" weight="bold">
{getText('scanQR')}
</Text>
<Text>{getText('scanQRDescription')}</Text>
</Text.Group>
</Alert>
<Text>{getText('scanQRDescription')}</Text>
</Text.Group>
</Alert>
<div className="self-center">
<LazyQRCode
value={data.url}
bgColor="transparent"
fgColor="rgb(0 0 0 / 60%)"
size={192}
className="rounded-2xl border-0.5 border-primary p-4"
/>
</div>
</>
)}
<div className="self-center">
<LazyQRCode
value={data.url}
bgColor="transparent"
fgColor="rgb(0 0 0 / 60%)"
size={192}
className="rounded-2xl border-0.5 border-primary p-4"
/>
</div>
</>
)
}
</Form.FieldValue>
<Form.FieldValue name="display">
{(display) =>
display === 'text' && (
<>
<Alert key="alert" variant="neutral" icon={ShieldCheck}>
<Text.Group>
<Text variant="subtitle" weight="bold">
{getText('copyLink')}
</Text>
<Text>{getText('copyLinkDescription')}</Text>
</Text.Group>
</Alert>
{field.value === 'text' && (
<>
<Alert variant="neutral" icon={ShieldCheck}>
<Text.Group>
<Text variant="subtitle" weight="bold">
{getText('copyLink')}
</Text>
<Text>{getText('copyLinkDescription')}</Text>
</Text.Group>
</Alert>
<CopyBlock copyText={data.url} />
</>
)}
<CopyBlock copyText={data.url} />
</>
)
}
</Form.FieldValue>
<OTPInput
className="max-w-96"

View File

@ -70,7 +70,7 @@ export default function SettingsTab(props: SettingsTabProps) {
))}
</div>
: <div
className="flex min-h-full grow flex-col gap-8 lg:h-auto lg:flex-row"
className="grid min-h-full grow grid-cols-1 gap-8 lg:h-auto lg:grid-cols-2"
{...contentProps}
>
{columns.map((sectionsInColumn, i) => (

View File

@ -73,7 +73,7 @@ export default function UserGroupUserRow(props: UserGroupUserRowProps) {
</ariaComponents.Text>
</div>
</aria.Cell>
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
<aria.Cell className="relative bg-transparent p-0 opacity-0 rounded-rows-have-level group-hover-2:opacity-100">
{isAdmin && (
<ariaComponents.DialogTrigger>
<ariaComponents.Button

View File

@ -3,9 +3,8 @@ import * as React from 'react'
import * as modalProvider from '#/providers/ModalProvider'
import Modal from '#/components/Modal'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { DIALOG_BACKGROUND } from '../components/AriaComponents'
// =================
// === Constants ===
@ -45,8 +44,7 @@ export default function DragModal(props: DragModalProps) {
const { unsetModal } = modalProvider.useSetModal()
const [left, setLeft] = React.useState(event.pageX - (offsetPx ?? offsetXPx))
const [top, setTop] = React.useState(event.pageY - (offsetPx ?? offsetYPx))
const onDragEndRef = React.useRef(onDragEndRaw)
onDragEndRef.current = onDragEndRaw
const onDragEndOuter = useEventCallback(onDragEndRaw)
React.useEffect(() => {
const onDrag = (dragEvent: MouseEvent) => {
@ -56,8 +54,10 @@ export default function DragModal(props: DragModalProps) {
}
}
const onDragEnd = () => {
onDragEndRef.current()
unsetModal()
React.startTransition(() => {
onDragEndOuter()
unsetModal()
})
}
// Update position (non-FF)
document.addEventListener('drag', onDrag, { capture: true })
@ -69,17 +69,19 @@ export default function DragModal(props: DragModalProps) {
document.removeEventListener('dragover', onDrag, { capture: true })
document.removeEventListener('dragend', onDragEnd, { capture: true })
}
}, [offsetPx, offsetXPx, offsetYPx, unsetModal])
}, [offsetPx, offsetXPx, offsetYPx, onDragEndOuter, unsetModal])
return (
<Modal className="pointer-events-none absolute size-full overflow-hidden">
<div className="pointer-events-none absolute size-full overflow-hidden">
<div
{...passthrough}
style={{ left, top, ...style }}
className={tailwindMerge.twMerge('relative w-min', className)}
className={DIALOG_BACKGROUND({
className: ['relative z-10 w-min -translate-x-1/3 -translate-y-1/3', className],
})}
>
{children}
</div>
</Modal>
</div>
)
}

View File

@ -63,8 +63,8 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
const [conflictingFiles, setConflictingFiles] = React.useState(conflictingFilesRaw)
const [conflictingProjects, setConflictingProjects] = React.useState(conflictingProjectsRaw)
const [didUploadNonConflicting, setDidUploadNonConflicting] = React.useState(false)
const siblingFileNames = React.useRef(new Set<string>())
const siblingProjectNames = React.useRef(new Set<string>())
const [siblingFileNames] = React.useState(new Set<string>())
const [siblingProjectNames] = React.useState(new Set<string>())
const count = conflictingFiles.length + conflictingProjects.length
const firstConflict = conflictingFiles[0] ?? conflictingProjects[0]
const otherFilesCount = Math.max(0, conflictingFiles.length - 1)
@ -85,15 +85,15 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
React.useEffect(() => {
for (const name of siblingFileNamesRaw) {
siblingFileNames.current.add(name)
siblingFileNames.add(name)
}
for (const name of siblingProjectNamesRaw) {
siblingProjectNames.current.add(name)
siblingProjectNames.add(name)
}
// Note that because the props are `Iterable`s, they may be different each time
// even if their contents are identical. However, as this component should never
// be re-rendered with different props, the dependency list should not matter anyway.
}, [siblingFileNamesRaw, siblingProjectNamesRaw])
}, [siblingFileNames, siblingFileNamesRaw, siblingProjectNames, siblingProjectNamesRaw])
const findNewName = (conflict: ConflictingAsset, commit = true) => {
let title = conflict.file.name
@ -104,9 +104,9 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
while (true) {
i += 1
const candidateTitle = `${basename} ${i}.${extension}`
if (!siblingFileNames.current.has(candidateTitle)) {
if (!siblingFileNames.has(candidateTitle)) {
if (commit) {
siblingFileNames.current.add(candidateTitle)
siblingFileNames.add(candidateTitle)
}
title = candidateTitle
break
@ -121,9 +121,9 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
while (true) {
i += 1
const candidateTitle = `${title} ${i}`
if (!siblingProjectNames.current.has(candidateTitle)) {
if (!siblingProjectNames.has(candidateTitle)) {
if (commit) {
siblingProjectNames.current.add(candidateTitle)
siblingProjectNames.add(candidateTitle)
}
title = `${candidateTitle}.${extension}`
break

View File

@ -13,7 +13,7 @@ import PermissionSelector from '#/components/dashboard/PermissionSelector'
import Modal from '#/components/Modal'
import { PaywallAlert } from '#/components/Paywall'
import FocusArea from '#/components/styled/FocusArea'
import { backendMutationOptions, useAssetStrict } from '#/hooks/backendHooks'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { usePaywall } from '#/hooks/billing'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import type { Category } from '#/layouts/CategorySwitcher/Category'
@ -74,13 +74,8 @@ export interface ManagePermissionsModalProps<Asset extends AnyAsset = AnyAsset>
export default function ManagePermissionsModal<Asset extends AnyAsset = AnyAsset>(
props: ManagePermissionsModalProps<Asset>,
) {
const { backend, category, item: itemRaw, self, doRemoveSelf, eventTarget } = props
const item = useAssetStrict({
backend,
assetId: itemRaw.id,
parentId: itemRaw.parentId,
category,
})
const { item, self, doRemoveSelf, eventTarget } = props
const remoteBackend = useRemoteBackend()
const { user } = useFullUserSession()
const { unsetModal } = useSetModal()

View File

@ -13,6 +13,7 @@ import { Button, ButtonGroup } from '#/components/AriaComponents'
import { useMounted } from '#/hooks/mountHooks'
import * as authProvider from '#/providers/AuthProvider'
import { useText } from '#/providers/TextProvider'
import { unsafeWriteValue } from '#/utilities/write'
import { useMutation } from '@tanstack/react-query'
import AuthenticationPage from './AuthenticationPage'
@ -38,7 +39,7 @@ export default function ConfirmRegistration() {
auth.confirmSignUp(params.email, params.verificationCode),
onSuccess: () => {
if (redirectUrl != null) {
window.location.href = redirectUrl
unsafeWriteValue(window.location, 'href', redirectUrl)
} else {
searchParams.delete('verification_code')
searchParams.delete('email')

View File

@ -119,6 +119,7 @@ export default function Login() {
<Form form={form} gap="medium">
<Input
form={form}
autoFocus
required
data-testid="email-input"
@ -135,6 +136,7 @@ export default function Login() {
<div className="flex w-full flex-col">
<Password
form={form}
required
data-testid="password-input"
name="password"

View File

@ -15,7 +15,6 @@ import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import ProjectsProvider, {
TabType,
@ -104,7 +103,6 @@ function DashboardInner(props: DashboardProps) {
const localBackend = backendProvider.useLocalBackend()
const { modalRef } = modalProvider.useModalRef()
const { updateModal, unsetModal, setModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
const inputBindings = inputBindingsProvider.useInputBindings()
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
@ -207,7 +205,7 @@ function DashboardInner(props: DashboardProps) {
}
},
}),
[inputBindings, modalRef, localStorage, updateModal, setPage, projectsStore],
[inputBindings, modalRef, updateModal, setPage, projectsStore],
)
React.useEffect(() => {

View File

@ -37,6 +37,7 @@ import * as errorModule from '#/utilities/error'
import * as cognitoModule from '#/authentication/cognito'
import type * as authServiceModule from '#/authentication/service'
import { unsafeWriteValue } from '#/utilities/write'
// ===================
// === UserSession ===
@ -204,7 +205,7 @@ export default function AuthProvider(props: AuthProviderProps) {
await cognito.signOut()
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
unsafeWriteValue(document, 'cookie', `logged_in=no;max-age=0;domain=${parentDomain}`)
gtagEvent('cloud_sign_out')
cognito.saveAccessToken(null)
localStorage.clearUserSpecificEntries()

View File

@ -24,13 +24,20 @@ import type RemoteBackend from '#/services/RemoteBackend'
export interface BackendContextType {
readonly remoteBackend: RemoteBackend | null
readonly localBackend: LocalBackend | null
readonly didLoadingProjectManagerFail: boolean
readonly reconnectToProjectManager: () => void
}
const BackendContext = React.createContext<BackendContextType>({
remoteBackend: null,
localBackend: null,
})
/** State contained in a `ProjectManagerContext`. */
export interface ProjectManagerContextType {
readonly didLoadingProjectManagerFail: boolean
readonly reconnectToProjectManager: () => void
}
const ProjectManagerContext = React.createContext<ProjectManagerContextType>({
didLoadingProjectManagerFail: false,
reconnectToProjectManager: () => {},
})
@ -54,6 +61,7 @@ export default function BackendProvider(props: BackendProviderProps) {
const onProjectManagerLoadingFailed = () => {
setDidLoadingProjectManagerFail(true)
}
document.addEventListener(ProjectManagerEvents.loadingFailed, onProjectManagerLoadingFailed)
return () => {
document.removeEventListener(
@ -69,15 +77,12 @@ export default function BackendProvider(props: BackendProviderProps) {
})
return (
<BackendContext.Provider
value={{
remoteBackend,
localBackend,
didLoadingProjectManagerFail,
reconnectToProjectManager,
}}
>
{children}
<BackendContext.Provider value={{ remoteBackend, localBackend }}>
<ProjectManagerContext.Provider
value={{ didLoadingProjectManagerFail, reconnectToProjectManager }}
>
{children}
</ProjectManagerContext.Provider>
</BackendContext.Provider>
)
}
@ -153,10 +158,10 @@ export function useBackendForProjectType(projectType: BackendType) {
/** Whether connecting to the Project Manager failed. */
export function useDidLoadingProjectManagerFail() {
return React.useContext(BackendContext).didLoadingProjectManagerFail
return React.useContext(ProjectManagerContext).didLoadingProjectManagerFail
}
/** Reconnect to the Project Manager. */
export function useReconnectToProjectManager() {
return React.useContext(BackendContext).reconnectToProjectManager
return React.useContext(ProjectManagerContext).reconnectToProjectManager
}

View File

@ -5,10 +5,7 @@ import * as zustand from '#/utilities/zustand'
import invariant from 'tiny-invariant'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import type { AssetPanelContextProps } from '#/layouts/AssetPanel'
import type { Suggestion } from '#/layouts/AssetSearchBar'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import type { PasteData } from '#/utilities/pasteData'
import { EMPTY_SET } from '#/utilities/set'
@ -51,18 +48,6 @@ interface DriveStore {
readonly setSelectedKeys: (selectedKeys: ReadonlySet<AssetId>) => void
readonly visuallySelectedKeys: ReadonlySet<AssetId> | null
readonly setVisuallySelectedKeys: (visuallySelectedKeys: ReadonlySet<AssetId> | null) => void
readonly isAssetPanelPermanentlyVisible: boolean
readonly setIsAssetPanelExpanded: (isAssetPanelExpanded: boolean) => void
readonly setIsAssetPanelPermanentlyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
readonly toggleIsAssetPanelPermanentlyVisible: () => void
readonly isAssetPanelTemporarilyVisible: boolean
readonly setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible: boolean) => void
readonly assetPanelProps: AssetPanelContextProps
readonly setAssetPanelProps: (assetPanelProps: Partial<AssetPanelContextProps>) => void
readonly suggestions: readonly Suggestion[]
readonly setSuggestions: (suggestions: readonly Suggestion[]) => void
readonly isAssetPanelHidden: boolean
readonly setIsAssetPanelHidden: (isAssetPanelHidden: boolean) => void
}
// =======================
@ -87,7 +72,7 @@ export type ProjectsProviderProps = Readonly<React.PropsWithChildren>
*/
export default function DriveProvider(props: ProjectsProviderProps) {
const { children } = props
const { localStorage } = useLocalStorage()
const [store] = React.useState(() =>
zustand.createStore<DriveStore>((set, get) => ({
category: { type: 'cloud' },
@ -98,14 +83,6 @@ export default function DriveProvider(props: ProjectsProviderProps) {
targetDirectory: null,
selectedKeys: EMPTY_SET,
visuallySelectedKeys: null,
suggestions: EMPTY_ARRAY,
assetPanelProps: {
selectedTab: get().assetPanelProps.selectedTab,
backend: null,
item: null,
spotlightOn: null,
path: null,
},
})
}
},
@ -157,69 +134,6 @@ export default function DriveProvider(props: ProjectsProviderProps) {
set({ visuallySelectedKeys })
}
},
isAssetPanelPermanentlyVisible: localStorage.get('isAssetPanelVisible') ?? false,
toggleIsAssetPanelPermanentlyVisible: () => {
const state = get()
const next = !state.isAssetPanelPermanentlyVisible
state.setIsAssetPanelPermanentlyVisible(next)
},
setIsAssetPanelPermanentlyVisible: (isAssetPanelPermanentlyVisible) => {
if (get().isAssetPanelPermanentlyVisible !== isAssetPanelPermanentlyVisible) {
set({ isAssetPanelPermanentlyVisible })
localStorage.set('isAssetPanelVisible', isAssetPanelPermanentlyVisible)
}
},
setIsAssetPanelExpanded: (isAssetPanelExpanded) => {
const state = get()
if (state.isAssetPanelPermanentlyVisible !== isAssetPanelExpanded) {
state.setIsAssetPanelPermanentlyVisible(isAssetPanelExpanded)
state.setIsAssetPanelTemporarilyVisible(false)
}
if (state.isAssetPanelHidden && isAssetPanelExpanded) {
state.setIsAssetPanelHidden(false)
}
},
isAssetPanelTemporarilyVisible: false,
setIsAssetPanelTemporarilyVisible: (isAssetPanelTemporarilyVisible) => {
const state = get()
if (state.isAssetPanelHidden && isAssetPanelTemporarilyVisible) {
state.setIsAssetPanelHidden(false)
}
if (state.isAssetPanelTemporarilyVisible !== isAssetPanelTemporarilyVisible) {
set({ isAssetPanelTemporarilyVisible })
}
},
assetPanelProps: {
selectedTab: localStorage.get('assetPanelTab') ?? 'settings',
backend: null,
item: null,
spotlightOn: null,
path: null,
},
setAssetPanelProps: (assetPanelProps) => {
const current = get().assetPanelProps
if (current !== assetPanelProps) {
set({ assetPanelProps: { ...current, ...assetPanelProps } })
}
},
suggestions: EMPTY_ARRAY,
setSuggestions: (suggestions) => {
set({ suggestions })
},
isAssetPanelHidden: localStorage.get('isAssetPanelHidden') ?? false,
setIsAssetPanelHidden: (isAssetPanelHidden) => {
const state = get()
if (state.isAssetPanelHidden !== isAssetPanelHidden) {
set({ isAssetPanelHidden })
localStorage.set('isAssetPanelHidden', isAssetPanelHidden)
}
},
})),
)
@ -347,162 +261,6 @@ export function useSetVisuallySelectedKeys() {
})
}
/** Whether the Asset Panel is toggled on. */
export function useIsAssetPanelPermanentlyVisible() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.isAssetPanelPermanentlyVisible, {
unsafeEnableTransition: true,
})
}
/** A function to set whether the Asset Panel is toggled on. */
export function useSetIsAssetPanelPermanentlyVisible() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setIsAssetPanelPermanentlyVisible, {
unsafeEnableTransition: true,
})
}
/** 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, {
unsafeEnableTransition: true,
})
}
/** 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, {
unsafeEnableTransition: true,
})
}
/** Whether the Asset Panel is currently visible, either temporarily or permanently. */
export function useIsAssetPanelVisible() {
const isAssetPanelPermanentlyVisible = useIsAssetPanelPermanentlyVisible()
const isAssetPanelTemporarilyVisible = useIsAssetPanelTemporarilyVisible()
return isAssetPanelPermanentlyVisible || isAssetPanelTemporarilyVisible
}
/**
* Whether the Asset Panel is expanded.
*/
export function useIsAssetPanelExpanded() {
const store = useDriveStore()
return zustand.useStore(
store,
({ isAssetPanelPermanentlyVisible, isAssetPanelTemporarilyVisible }) =>
isAssetPanelPermanentlyVisible || isAssetPanelTemporarilyVisible,
{ unsafeEnableTransition: true },
)
}
/** A function to set whether the Asset Panel is expanded. */
export function useSetIsAssetPanelExpanded() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setIsAssetPanelExpanded, {
unsafeEnableTransition: true,
})
}
/** Props for the Asset Panel. */
export function useAssetPanelProps() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.assetPanelProps, {
unsafeEnableTransition: true,
areEqual: 'shallow',
})
}
/**
* The selected tab of the Asset Panel.
*/
export function useAssetPanelSelectedTab() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.assetPanelProps.selectedTab, {
unsafeEnableTransition: true,
})
}
/** A function to set props for the Asset Panel. */
export function useSetAssetPanelProps() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setAssetPanelProps, {
unsafeEnableTransition: true,
})
}
/**
* A function to reset the Asset Panel props to their default values.
*/
export function useResetAssetPanelProps() {
const store = useDriveStore()
return useEventCallback(() => {
const current = store.getState().assetPanelProps
if (current.item != null) {
store.setState({
assetPanelProps: {
selectedTab: current.selectedTab,
backend: null,
item: null,
spotlightOn: null,
path: null,
},
})
}
})
}
/**
* A function to set the selected tab of the Asset Panel.
*/
export function useSetAssetPanelSelectedTab() {
const store = useDriveStore()
return useEventCallback((selectedTab: AssetPanelContextProps['selectedTab']) => {
const current = store.getState().assetPanelProps
if (current.selectedTab !== selectedTab) {
store.setState({
assetPanelProps: { ...current, selectedTab },
})
}
})
}
/** Search suggestions. */
export function useSuggestions() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.suggestions, {
unsafeEnableTransition: true,
})
}
/** Set search suggestions. */
export function useSetSuggestions() {
const store = useDriveStore()
const setSuggestions = zustand.useStore(store, (state) => state.setSuggestions)
return useEventCallback((suggestions: readonly Suggestion[]) => {
React.startTransition(() => {
setSuggestions(suggestions)
})
})
}
/** Whether the Asset Panel is hidden. */
export function useIsAssetPanelHidden() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.isAssetPanelHidden)
}
/** A function to set whether the Asset Panel is hidden. */
export function useSetIsAssetPanelHidden() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setIsAssetPanelHidden)
}
/** Toggle whether a specific directory is expanded. */
export function useToggleDirectoryExpansion() {
const driveStore = useDriveStore()

View File

@ -31,7 +31,8 @@ export type LocalStorageProviderProps = Readonly<React.PropsWithChildren>
/** A React Provider that lets components get the shortcut registry. */
export default function LocalStorageProvider(props: LocalStorageProviderProps) {
const { children } = props
const [localStorage] = React.useState(() => new LocalStorage())
const localStorage = React.useMemo(() => LocalStorage.getInstance(), [])
return (
<LocalStorageContext.Provider value={{ localStorage }}>{children}</LocalStorageContext.Provider>

View File

@ -20,8 +20,9 @@
--color-invert-opacity: 100%;
--color-invert: rgb(var(--color-invert-rgb) / var(--color-invert-opacity));
--color-background-rgb: 255 255 255;
--color-background-opacity: 80%;
--color-background-opacity: 60%;
--color-background: rgb(var(--color-background-rgb) / var(--color-background-opacity));
--color-background-hex: #fdfcfb;
--color-dashboard-background-rgb: 239 234 228;
--color-dashboard-background-opacity: 100%;
@ -479,23 +480,6 @@
border-right-width: 4px;
border-left-width: 0;
/* Custom scrollbar for rounded corners */
:where(:is([class*='rounded-xl']))& {
--scrollbar-offset-edge: 8px;
}
:where(:is([class*='rounded-2xl']))& {
--scrollbar-offset-edge: 16px;
}
:where(:is([class*='rounded-3xl']))& {
--scrollbar-offset-edge: 24px;
}
:where(:is([class*='rounded-4xl']))& {
--scrollbar-offset-edge: 28px;
}
&:hover {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" style="color:rgb(0 0 0 / 0.25)" fill="currentColor" viewBox="0 0 5 5" preserveAspectRatio="xMidYMid meet"><path d="M0 0H5V2.5C5 3.88071 3.88071 5 2.5 5V5C1.11929 5 0 3.88071 0 2.5V0Z" /></svg>'),
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" style="color:rgb(0 0 0 / 0.25)" fill="currentColor" viewBox="0 0 5 5" preserveAspectRatio="xMidYMid meet"><path d="M0 2.5C0 1.11929 1.11929 0 2.5 0V0C3.88071 0 5 1.11929 5 2.5V5H0V2.5Z"/></svg>'),
@ -522,23 +506,6 @@
border-top-width: 0;
border-bottom-width: 4px;
/* Custom scrollbar for rounded corners */
:where(:is([class*='rounded-xl']))& {
--scrollbar-offset-edge: 8px;
}
:where(:is([class*='rounded-2xl']))& {
--scrollbar-offset-edge: 16px;
}
:where(:is([class*='rounded-3xl']))& {
--scrollbar-offset-edge: 24px;
}
:where(:is([class*='rounded-4xl']))& {
--scrollbar-offset-edge: 28px;
}
&:hover {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" style="color:rgb(0 0 0 / 0.25); transform: rotate(90deg)" fill="currentColor" viewBox="0 0 5 5" preserveAspectRatio="xMidYMid meet"><path d="M0 0H5V2.5C5 3.88071 3.88071 5 2.5 5V5C1.11929 5 0 3.88071 0 2.5V0Z" /></svg>'),
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" style="color:rgb(0 0 0 / 0.25); transform: rotate(90deg)" fill="currentColor" viewBox="0 0 5 5" preserveAspectRatio="xMidYMid meet"><path d="M0 2.5C0 1.11929 1.11929 0 2.5 0V0C3.88071 0 5 1.11929 5 2.5V5H0V2.5Z"/></svg>'),

View File

@ -61,32 +61,38 @@ export default class LocalStorage {
// This is UNSAFE. It is assumed that `LocalStorage.register` is always called
// when `LocalStorageData` is declaration merged into.
// eslint-disable-next-line no-restricted-syntax
static keyMetadata = {} as Record<LocalStorageKey, LocalStorageKeyMetadata<LocalStorageKey>>
private static keyMetadata = {} as Record<
LocalStorageKey,
LocalStorageKeyMetadata<LocalStorageKey>
>
private static instance: LocalStorage | null = null
localStorageKey = common.PRODUCT_NAME.toLowerCase()
protected values: Partial<LocalStorageData>
private readonly eventTarget = new EventTarget()
/** Create a {@link LocalStorage}. */
constructor() {
const savedValues: unknown = JSON.parse(localStorage.getItem(this.localStorageKey) ?? '{}')
const newValues: Partial<Record<LocalStorageKey, LocalStorageData[LocalStorageKey]>> = {}
if (typeof savedValues === 'object' && savedValues != null) {
for (const [key, metadata] of object.unsafeEntries(LocalStorage.keyMetadata)) {
if (key in savedValues) {
// This is SAFE, as it is guarded by the `key in savedValues` check.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
const savedValue = (savedValues as any)[key]
const value = metadata.schema.safeParse(savedValue).data
if (value != null) {
newValues[key] = value
}
}
}
private constructor() {
this.values = {}
}
/**
* Gets the singleton instance of {@link LocalStorage}.
*/
static getInstance() {
if (LocalStorage.instance == null) {
LocalStorage.instance = new LocalStorage()
}
// This is SAFE, as the `tryParse` function is required by definition to return a value of the
// correct type for each corresponding key.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
this.values = newValues as any
return LocalStorage.instance
}
/**
* Get all registered keys.
*/
static getAllKeys() {
// This is SAFE because `LocalStorage.keyMetadata` is a statically known set of keys.
// eslint-disable-next-line no-restricted-syntax
return Object.keys(LocalStorage.keyMetadata) as LocalStorageKey[]
}
/** Register runtime behavior associated with a {@link LocalStorageKey}. */
@ -103,31 +109,41 @@ export default class LocalStorage {
/** Register runtime behavior associated with a {@link LocalStorageKey}. */
static register<K extends LocalStorageKey>(metadata: { [K_ in K]: LocalStorageKeyMetadata<K_> }) {
for (const key in metadata) {
if (IS_DEV_MODE ? isSourceChanged(key) : true) {
invariant(
!(key in LocalStorage.keyMetadata),
`Local storage key '${key}' has already been registered.`,
)
}
LocalStorage.registerKey(key, metadata[key])
}
Object.assign(LocalStorage.keyMetadata, metadata)
}
/** Retrieve an entry from the stored data. */
get<K extends LocalStorageKey>(key: K) {
this.assertRegisteredKey(key)
if (!(key in this.values)) {
const value = this.readValueFromLocalStorage(key)
if (value != null) {
this.values[key] = value
}
}
return this.values[key]
}
/** Write an entry to the stored data, and save. */
set<K extends LocalStorageKey>(key: K, value: LocalStorageData[K]) {
this.assertRegisteredKey(key)
this.values[key] = value
this.eventTarget.dispatchEvent(new Event(key))
this.eventTarget.dispatchEvent(new Event('_change'))
this.save()
}
/** Delete an entry from the stored data, and save. */
delete<K extends LocalStorageKey>(key: K) {
this.assertRegisteredKey(key)
const oldValue = this.values[key]
// The key being deleted is one of a statically known set of keys.
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
@ -135,6 +151,7 @@ export default class LocalStorage {
this.eventTarget.dispatchEvent(new Event(key))
this.eventTarget.dispatchEvent(new Event('_change'))
this.save()
return oldValue
}
@ -153,9 +170,10 @@ export default class LocalStorage {
callback: (value: LocalStorageData[K] | undefined) => void,
) {
const onChange = () => {
callback(this.values[key])
callback(this.get(key))
}
this.eventTarget.addEventListener(key, onChange)
return () => {
this.eventTarget.removeEventListener(key, onChange)
}
@ -167,6 +185,7 @@ export default class LocalStorage {
callback(this.values)
}
this.eventTarget.addEventListener('_change', onChange)
return () => {
this.eventTarget.removeEventListener('_change', onChange)
}
@ -176,4 +195,50 @@ export default class LocalStorage {
protected save() {
localStorage.setItem(this.localStorageKey, JSON.stringify(this.values))
}
/**
* Whether the key has been registered.
* @throws {Error} If the key has not been registered yet.
*/
private assertRegisteredKey(key: LocalStorageKey): asserts key is LocalStorageKey {
if (key in LocalStorage.keyMetadata) {
return
}
throw new Error(
`Local storage key '${key}' has not been registered yet. Please register it first.`,
)
}
/** Read a value from the stored data. */
private readValueFromLocalStorage<
Key extends LocalStorageKey,
Value extends LocalStorageData[Key],
>(key: Key): Value | null {
this.assertRegisteredKey(key)
const storedValues = localStorage.getItem(this.localStorageKey)
const savedValues: unknown = JSON.parse(storedValues ?? '{}')
if (typeof savedValues === 'object' && savedValues != null && key in savedValues) {
// @ts-expect-error This is SAFE, as it is guarded by the `key in savedValues` check.
const savedValue: unknown = savedValues[key]
const parsedValue = LocalStorage.keyMetadata[key].schema.safeParse(savedValue)
if (parsedValue.success) {
// This is safe because the schema is validated before this code is reached.
// eslint-disable-next-line no-restricted-syntax
return parsedValue.data as Value
}
// eslint-disable-next-line no-restricted-properties
console.warn('LocalStorage failed to parse value', {
key,
savedValue,
error: parsedValue.error,
})
}
return null
}
}

View File

@ -0,0 +1,8 @@
/**
* @file
* A collection of utilities that work with functions.
*/
/** A function that does nothing, except stable reference. */
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any
export const noop: (...args: any[]) => void = () => {}

View File

@ -0,0 +1,14 @@
/** @file Functions related to writing values to objects. */
/**
* "Unsafe" because it bypasses React Compiler checks.
* This function exists to bypass the React Compiler expecting values
* (`document`, `window`, object refs passed in) to not be mutated.
*/
export function unsafeWriteValue<T extends object, K extends keyof T>(
object: T,
key: K,
value: T[K],
) {
object[key] = value
}

View File

@ -21,6 +21,8 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
primary: 'rgb(var(--color-primary-rgb) / var(--color-primary-opacity))',
invert: 'rgb(var(--color-invert-rgb) / var(--color-invert-opacity))',
background: 'rgb(var(--color-background-rgb) / var(--color-background-opacity))',
'background-hex': 'var(--color-background-hex)',
dashboard:
'rgb(var(--color-dashboard-background-rgb) / var(--color-dashboard-background-opacity))',
accent: 'rgb(var(--color-accent-rgb) / 100%)',
@ -546,20 +548,32 @@ inset 0 -36px 51px -51px #00000014`,
'.rounded-rows': {
[`:where(
& > tbody > tr:nth-child(odd of .rounded-rows-child) > td:not(.rounded-rows-skip-level),
& > tbody > tr:nth-child(odd of .rounded-rows-child) > td.rounded-rows-skip-level > *
& :nth-child(odd of .rounded-rows-child) > .rounded-rows-have-level
)`]: {
backgroundColor: `rgb(0 0 0 / 3%)`,
},
[`:where(
& > tbody > tr.rounded-rows-child.selected > td:not(.rounded-rows-skip-level),
& > tbody > tr.rounded-rows-child.selected > td.rounded-rows-skip-level > *
& :nth-child(odd of .rounded-rows-child) > .rounded-rows-skip-level > .rounded-rows-child
)`]: {
backgroundColor: `rgb(0 0 0 / 3%)`,
},
[`:where(
& .selected > .rounded-rows-have-level
)`]: {
backgroundColor: 'rgb(255 255 255 / 90%)',
},
[`:where(
& > tbody > tr.rounded-rows-child[data-drop-target] > td:not(.rounded-rows-skip-level),
& > tbody > tr.rounded-rows-child[data-drop-target] > td.rounded-rows-skip-level > *
& .selected > .rounded-rows-skip-level > .rounded-rows-child
)`]: {
backgroundColor: 'rgb(255 255 255 / 90%)',
},
[`:where(
& [data-drop-target]:nth-child(odd of .rounded-rows-child) > .rounded-rows-have-level
)`]: {
backgroundColor: 'rgb(0 0 0 / 8%)',
},
[`:where(
& [data-drop-target]:nth-child(odd of .rounded-rows-child) > .rounded-rows-skip-level > .rounded-rows-child
)`]: {
backgroundColor: 'rgb(0 0 0 / 8%)',
},

Some files were not shown because too many files have changed in this diff Show More