Virtualization draft

This commit is contained in:
Sergei Garin 2024-11-01 14:14:35 +03:00
parent ab9e7ef9c2
commit bf098cdc54
No known key found for this signature in database
6 changed files with 102 additions and 71 deletions

View File

@ -67,10 +67,21 @@ export function DialogTrigger(props: DialogTriggerProps) {
} satisfies DialogTriggerRenderProps } satisfies DialogTriggerRenderProps
return ( return (
<aria.DialogTrigger {...state} onOpenChange={onOpenChangeInternal}> <DialogTriggerInner {...state} onOpenChange={onOpenChangeInternal}>
{trigger} {trigger}
{typeof dialog === 'function' ? dialog(renderProps) : dialog} {state.isOpen ?
</aria.DialogTrigger> typeof dialog === 'function' ?
dialog(renderProps)
: dialog
: null}
</DialogTriggerInner>
) )
} }
/**
* A DialogTriggerInner is a DialogTrigger that has a stable reference.
*/
function DialogTriggerInner(props: aria.DialogTriggerProps) {
return <aria.DialogTrigger {...props} />
}

View File

@ -113,15 +113,12 @@ export interface AssetRowProps {
event: React.DragEvent<HTMLTableRowElement>, event: React.DragEvent<HTMLTableRowElement>,
item: backendModule.AnyAsset, item: backendModule.AnyAsset,
) => void ) => void
readonly rowHeight: number
readonly rowOffset: number
} }
/** A row containing an {@link backendModule.AnyAsset}. */ /** A row containing an {@link backendModule.AnyAsset}. */
export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const { id, parentId, isKeyboardSelected, isOpened, select, state, columns, onClick } = props const { id, parentId, isKeyboardSelected, isOpened, select, state, columns, onClick } = props
const { path, hidden: hiddenRaw, grabKeyboardFocus, visibility: visibilityRaw, depth } = props const { path, hidden: hiddenRaw, grabKeyboardFocus, visibility: visibilityRaw, depth } = props
const { rowHeight, rowOffset } = props
const { initialAssetEvents } = props const { initialAssetEvents } = props
const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state
const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state
@ -509,10 +506,9 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
<> <>
{!hidden && ( {!hidden && (
<FocusRing> <FocusRing>
<tr <div
data-testid="asset-row" data-testid="asset-row"
tabIndex={0} tabIndex={0}
style={{ maxHeight: rowHeight, transform: `translateY(${rowOffset}px)` }}
ref={(element) => { ref={(element) => {
rootRef.current = element rootRef.current = element
@ -699,7 +695,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
</td> </td>
) )
})} })}
</tr> </div>
</FocusRing> </FocusRing>
)} )}
{selected && allowContextMenu && !hidden && ( {selected && allowContextMenu && !hidden && (
@ -724,7 +720,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} }
case backendModule.AssetType.specialLoading: { case backendModule.AssetType.specialLoading: {
return hidden ? null : ( return hidden ? null : (
<tr> <div>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level"> <td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div <div
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
@ -735,12 +731,12 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
<StatelessSpinner size={24} state={statelessSpinner.SpinnerState.loadingMedium} /> <StatelessSpinner size={24} state={statelessSpinner.SpinnerState.loadingMedium} />
</div> </div>
</td> </td>
</tr> </div>
) )
} }
case backendModule.AssetType.specialEmpty: { case backendModule.AssetType.specialEmpty: {
return hidden ? null : ( return hidden ? null : (
<tr> <div>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level"> <td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div <div
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
@ -754,12 +750,12 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
</Text> </Text>
</div> </div>
</td> </td>
</tr> </div>
) )
} }
case backendModule.AssetType.specialError: { case backendModule.AssetType.specialError: {
return hidden ? null : ( return hidden ? null : (
<tr> <div>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level"> <td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div <div
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
@ -776,7 +772,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
</Text> </Text>
</div> </div>
</td> </td>
</tr> </div>
) )
} }
} }

View File

@ -16,6 +16,7 @@ import SvgMask from '#/components/SvgMask'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as eventModule from '#/utilities/event' import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent' import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
@ -46,7 +47,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory')) const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
const setIsEditing = (isEditingName: boolean) => { const setIsEditing = useEventCallback((isEditingName: boolean) => {
if (isEditable) { if (isEditable) {
setRowState(object.merger({ isEditingName })) setRowState(object.merger({ isEditingName }))
} }
@ -54,20 +55,38 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
if (!isEditingName) { if (!isEditingName) {
driveStore.setState({ newestFolderId: null }) driveStore.setState({ newestFolderId: null })
} }
} })
const doRename = async (newTitle: string) => { const doRename = useEventCallback(async (newTitle: string) => {
if (isEditable) { if (isEditable) {
setIsEditing(false) setIsEditing(false)
if (!string.isWhitespaceOnly(newTitle) && newTitle !== item.title) { if (!string.isWhitespaceOnly(newTitle) && newTitle !== item.title) {
await updateDirectoryMutation.mutateAsync([item.id, { title: newTitle }, item.title]) await updateDirectoryMutation.mutateAsync([item.id, { title: newTitle }, item.title])
} }
} }
} })
const toggleExpanded = useEventCallback(() => {
doToggleDirectoryExpansion(item.id, item.id)
})
const checkSubmittable = useEventCallback(
(newTitle: string) =>
validation.DIRECTORY_NAME_REGEX.test(newTitle) &&
backendModule.isNewTitleValid(
item,
newTitle,
nodeMap.current.get(item.parentId)?.children?.map((child) => child.item),
),
)
const onCancel = useEventCallback(() => {
setIsEditing(false)
})
return ( return (
<div <div
className={tailwindMerge.twMerge( className={tailwindMerge.twJoin(
'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', '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',
indent.indentClass(depth), indent.indentClass(depth),
)} )}
@ -93,34 +112,23 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
variant="custom" variant="custom"
aria-label={isExpanded ? getText('collapse') : getText('expand')} aria-label={isExpanded ? getText('collapse') : getText('expand')}
tooltipPlacement="left" 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', 'm-0 hidden cursor-pointer border-0 transition-transform duration-arrow group-hover:m-name-column-icon group-hover:inline-block',
isExpanded && 'rotate-90', isExpanded && 'rotate-90',
)} )}
onPress={() => { onPress={toggleExpanded}
doToggleDirectoryExpansion(item.id, item.id)
}}
/> />
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" /> <SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />
<EditableSpan <EditableSpan
data-testid="asset-row-name" data-testid="asset-row-name"
editable={rowState.isEditingName} editable={rowState.isEditingName}
className={tailwindMerge.twMerge( className={tailwindMerge.twJoin(
'grow cursor-pointer bg-transparent font-naming', 'grow cursor-pointer bg-transparent font-naming',
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer', rowState.isEditingName ? 'cursor-text' : 'cursor-pointer',
)} )}
checkSubmittable={(newTitle) => checkSubmittable={checkSubmittable}
validation.DIRECTORY_NAME_REGEX.test(newTitle) &&
backendModule.isNewTitleValid(
item,
newTitle,
nodeMap.current.get(item.parentId)?.children?.map((child) => child.item),
)
}
onSubmit={doRename} onSubmit={doRename}
onCancel={() => { onCancel={onCancel}
setIsEditing(false)
}}
> >
{item.title} {item.title}
</EditableSpan> </EditableSpan>

View File

@ -20,6 +20,7 @@ import * as backendModule from '#/services/Backend'
import { useBackendQuery } from '#/hooks/backendHooks' import { useBackendQuery } from '#/hooks/backendHooks'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
// ================= // =================
// === Constants === // === Constants ===
@ -137,15 +138,15 @@ export default function ProjectIcon(props: ProjectIconProps) {
} }
})() })()
const doOpenProject = () => { const doOpenProject = useEventCallback(() => {
openProject({ ...item, type: backend.type }) openProject({ ...item, type: backend.type })
} })
const doCloseProject = () => { const doCloseProject = useEventCallback(() => {
closeProject({ ...item, type: backend.type }) closeProject({ ...item, type: backend.type })
} })
const doOpenProjectTab = () => { const doOpenProjectTab = useEventCallback(() => {
openProjectTab(item.id) openProjectTab(item.id)
} })
switch (state) { switch (state) {
case backendModule.ProjectState.new: case backendModule.ProjectState.new:

View File

@ -20,6 +20,7 @@ import ManageLabelsModal from '#/modals/ManageLabelsModal'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as permissions from '#/utilities/permissions' import * as permissions from '#/utilities/permissions'
// ==================== // ====================
@ -44,6 +45,12 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
(self?.permission === permissions.PermissionAction.own || (self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin) self?.permission === permissions.PermissionAction.admin)
const onPress = useEventCallback(() => {})
const renderManageLabelsModal = useEventCallback(() => (
<ManageLabelsModal backend={backend} item={item} />
))
return ( return (
<div className="group flex items-center gap-column-items"> <div className="group flex items-center gap-column-items">
{(item.labels ?? []) {(item.labels ?? [])
@ -93,7 +100,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
isDisabled isDisabled
key={label} key={label}
color={labelsByName.get(label)?.color ?? backendModule.COLORS[0]} color={labelsByName.get(label)?.color ?? backendModule.COLORS[0]}
onPress={() => {}} onPress={onPress}
> >
{label} {label}
</Label> </Label>
@ -101,7 +108,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
{managesThisAsset && ( {managesThisAsset && (
<DialogTrigger> <DialogTrigger>
<Button variant="ghost" showIconOnHover icon={Plus2Icon} /> <Button variant="ghost" showIconOnHover icon={Plus2Icon} />
<ManageLabelsModal backend={backend} item={item} /> {renderManageLabelsModal}
</DialogTrigger> </DialogTrigger>
)} )}
</div> </div>

View File

@ -2574,7 +2574,7 @@ export default function AssetsTable(props: AssetsTableProps) {
count: displayItems.length, count: displayItems.length,
getScrollElement: () => rootRef.current, getScrollElement: () => rootRef.current,
estimateSize: () => ROW_HEIGHT_PX, estimateSize: () => ROW_HEIGHT_PX,
overscan: 20, overscan: 0,
}) })
const columns = useMemo( const columns = useMemo(
@ -2614,31 +2614,38 @@ export default function AssetsTable(props: AssetsTableProps) {
} }
return ( return (
<AssetRow <tr
key={item.key + item.path} key={index}
rowHeight={virtualRow.size} data-testid="asset-row"
rowOffset={virtualRow.start - index * virtualRow.size} tabIndex={0}
isOpened={openedProjects.some(({ id }) => item.item.id === id)} style={{
visibility={visibilities.get(item.key)} maxHeight: virtualRow.size,
columns={columns} transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`,
id={item.item.id} }}
parentId={item.directoryId} >
path={item.path} <AssetRow
initialAssetEvents={item.initialAssetEvents} isOpened={openedProjects.some(({ id }) => item.item.id === id)}
depth={item.depth} visibility={visibilities.get(item.key)}
state={state} columns={columns}
hidden={hidden || visibilities.get(item.key) === Visibility.hidden} id={item.item.id}
isKeyboardSelected={ parentId={item.directoryId}
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex] path={item.path}
} initialAssetEvents={item.initialAssetEvents}
grabKeyboardFocus={grabRowKeyboardFocus} depth={item.depth}
onClick={onRowClick} state={state}
select={selectRow} hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
onDragStart={onRowDragStart} isKeyboardSelected={
onDragOver={onRowDragOver} keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
onDragEnd={onRowDragEnd} }
onDrop={onRowDrop} grabKeyboardFocus={grabRowKeyboardFocus}
/> onClick={onRowClick}
select={selectRow}
onDragStart={onRowDragStart}
onDragOver={onRowDragOver}
onDragEnd={onRowDragEnd}
onDrop={onRowDrop}
/>
</tr>
) )
}) })
@ -2690,7 +2697,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}} }}
> >
<table className="isolate table-fixed border-collapse rounded-rows"> <table className="isolate table-fixed border-collapse rounded-rows">
<thead className="sticky top-0 z-1 bg-dashboard">{headerRow}</thead> <thead className="bg-dashboard sticky top-0 z-1">{headerRow}</thead>
<tbody ref={bodyRef}> <tbody ref={bodyRef}>
{itemRows} {itemRows}
<tr className="hidden h-row first:table-row"> <tr className="hidden h-row first:table-row">
@ -2792,6 +2799,7 @@ export default function AssetsTable(props: AssetsTableProps) {
{...mergeProps<JSX.IntrinsicElements['div']>()(innerProps, { {...mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
className: 'flex-1 overflow-auto container-size w-full h-full', className: 'flex-1 overflow-auto container-size w-full h-full',
onKeyDown, onKeyDown,
ref: rootRef,
onBlur: (event) => { onBlur: (event) => {
if ( if (
event.relatedTarget instanceof HTMLElement && event.relatedTarget instanceof HTMLElement &&