mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 11:51:41 +03:00
Fix React Compiler lints + improve performance (#11450)
This commit is contained in:
parent
80ae5823dd
commit
8c2e2af5f7
@ -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'
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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 ===
|
||||
|
@ -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:.*/",
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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> = {
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
|
@ -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"
|
||||
|
80
app/gui/src/dashboard/components/IsolateLayout.tsx
Normal file
80
app/gui/src/dashboard/components/IsolateLayout.tsx
Normal 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>
|
||||
)
|
||||
})
|
@ -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"
|
||||
|
@ -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 ?
|
||||
|
@ -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 }} />
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 ===
|
||||
|
@ -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}
|
||||
|
@ -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) => {
|
||||
|
@ -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) =>
|
||||
|
@ -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) => {
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
)}
|
||||
|
@ -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) => {
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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 })
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
@ -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}`,
|
||||
}
|
||||
|
||||
// =====================
|
||||
|
@ -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),
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -1,5 +1,4 @@
|
||||
/** @file A styled focus ring. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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]>()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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(() => {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
||||
/*
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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 ===
|
||||
|
@ -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 />
|
||||
}
|
||||
|
@ -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}
|
||||
|
250
app/gui/src/dashboard/layouts/AssetPanel/AssetPanelState.ts
Normal file
250
app/gui/src/dashboard/layouts/AssetPanel/AssetPanelState.ts
Normal 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,
|
||||
})
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -3,4 +3,5 @@
|
||||
* Barrels for the `AssetPanel` component.
|
||||
*/
|
||||
export * from './AssetPanel'
|
||||
export * from './AssetPanelState'
|
||||
export { AssetPanelToggle, type AssetPanelToggleProps } from './components/AssetPanelToggle'
|
||||
|
10
app/gui/src/dashboard/layouts/AssetPanel/types.ts
Normal file
10
app/gui/src/dashboard/layouts/AssetPanel/types.ts
Normal 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]
|
@ -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')} />
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
@ -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],
|
||||
)
|
||||
}
|
||||
|
259
app/gui/src/dashboard/layouts/AssetsTable/assetTreeHooks.tsx
Normal file
259
app/gui/src/dashboard/layouts/AssetsTable/assetTreeHooks.tsx
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
|
@ -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) => {
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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) => (
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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')
|
||||
|
@ -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"
|
||||
|
@ -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(() => {
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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>
|
||||
|
@ -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>'),
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
8
app/gui/src/dashboard/utilities/functions.ts
Normal file
8
app/gui/src/dashboard/utilities/functions.ts
Normal 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 = () => {}
|
14
app/gui/src/dashboard/utilities/write.ts
Normal file
14
app/gui/src/dashboard/utilities/write.ts
Normal 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
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user