perf: window the notes grid, workspace file tree and knowledge documents table
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Stand-ins for rows a windowed list did not mount (see use-list-window.ts).
|
||||
* The height is computed geometry, so it is the one inline style these lists
|
||||
* carry; everything else stays in classes.
|
||||
*/
|
||||
export function ListWindowTableSpacer({ height, columns, className }: {
|
||||
height: number
|
||||
columns: number
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<tr aria-hidden="true" className={className}>
|
||||
<td colSpan={columns} style={{ height: `${height}px` }} />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -71,6 +71,7 @@ import type { MagicCanvasContentHandle } from './MagicCanvasContent'
|
||||
import { MagicCanvasThumbnail } from './MagicCanvasThumbnail'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { useGridListWindow } from './use-grid-columns'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import {
|
||||
EmptyState,
|
||||
@@ -460,6 +461,10 @@ export function MagicNotesWorkspace({
|
||||
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
|
||||
const [detailView, setDetailView] = useState<'notes'>()
|
||||
const overviewFocusRef = useRef('')
|
||||
// The note whose card takes focus back on returning to the overview; the
|
||||
// windowed note list keeps that card mounted.
|
||||
const [overviewReturnNoteId, setOverviewReturnNoteId] = useState('')
|
||||
const noteGridRef = useRef<HTMLDivElement>(null)
|
||||
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
|
||||
const [loadedCommentMode, setCommentMode] =
|
||||
useState<MagicNoteCommentMode>('immediate')
|
||||
@@ -1202,12 +1207,16 @@ export function MagicNotesWorkspace({
|
||||
}
|
||||
if (target.kind === 'create-note') {
|
||||
overviewFocusRef.current = 'magic-note-new'
|
||||
setOverviewReturnNoteId('')
|
||||
void createNote(target.title, true)
|
||||
.then(() => focusSwitchTarget(target))
|
||||
return
|
||||
}
|
||||
setDeletingNote(false)
|
||||
if (libraryView === 'notes') overviewFocusRef.current = `magic-note-select-${target.noteId}`
|
||||
if (libraryView === 'notes') {
|
||||
overviewFocusRef.current = `magic-note-select-${target.noteId}`
|
||||
setOverviewReturnNoteId(target.noteId)
|
||||
}
|
||||
setDetailView('notes')
|
||||
focusSwitchTarget(target)
|
||||
void loadDetail(target.noteId, target.entryId)
|
||||
@@ -1753,6 +1762,24 @@ export function MagicNotesWorkspace({
|
||||
// Overview shows every note full width; the split list appears only beside an open note.
|
||||
const notesExpanded = isNarrowWorkspace ? !detailView : notesPaneOpen || !detailView
|
||||
const notesSplit = libraryView === 'notes' && Boolean(detailView) && !isNarrowWorkspace && notesPaneOpen
|
||||
const visibleNoteIdsKey = visibleNotes.map((note) => note.id).join('\n')
|
||||
const visibleNoteIds = useMemo(
|
||||
() => (visibleNoteIdsKey ? visibleNoteIdsKey.split('\n') : []),
|
||||
[visibleNoteIdsKey]
|
||||
)
|
||||
// P5: thousands of notes mount only the card rows near the viewport.
|
||||
const noteWindow = useGridListWindow({
|
||||
itemIds: visibleNoteIds,
|
||||
gridRef: noteGridRef,
|
||||
scope: `${search}\n${activeTagFilter.join('\n')}`,
|
||||
// Selection, the open menu and the overview return target stay mounted.
|
||||
keepIds: [selectedNoteId, noteActionsId, overviewReturnNoteId],
|
||||
enabled: libraryView === 'notes' && loadStatus !== 'loading',
|
||||
active: libraryView === 'notes' && notesExpanded,
|
||||
layoutKey: detailView ? 'list' : 'cards',
|
||||
estimatedRowHeight: detailView ? 56 : 176,
|
||||
rowAttribute: 'data-magic-note-window-row'
|
||||
})
|
||||
const todoPaneWidthLimits = { minimum: 240, maximum: Math.max(240, todoLayoutWidth - 301) }
|
||||
const displayedTodoWidth = clampMagicNotesPaneWidth(todoPaneWidth, todoPaneWidthLimits)
|
||||
const indexExpanded = isNarrowLayout ? narrowIndexOpen : indexPaneOpen
|
||||
@@ -2182,6 +2209,61 @@ export function MagicNotesWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
const renderNoteCard = (note: MagicNoteSummary, index: number): React.JSX.Element => (
|
||||
<div className="magic-note-row" key={note.id} {...(noteWindow.windowed ? { role: 'listitem', 'aria-posinset': index + 1, 'aria-setsize': visibleNotes.length } : {})}>
|
||||
<button
|
||||
id={`magic-note-select-${note.id}`}
|
||||
className="magic-note-list-item"
|
||||
aria-current={selectedNoteId === note.id ? 'true' : undefined}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selectedNoteId !== note.id && requestDraftSwitch({
|
||||
kind: 'note',
|
||||
noteId: note.id
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="magic-note-list-item__title">
|
||||
{note.pinned && (
|
||||
<Pin aria-label={t('status.pinned')} size={12} />
|
||||
)}
|
||||
<span className="magic-note-list-item__title-text">{note.title}</span>
|
||||
<time dateTime={note.updatedAt} title={t('notes.updatedAt', { date: dateFormatter.format(new Date(note.updatedAt)) })}>
|
||||
{relativeDate(note.updatedAt)}
|
||||
</time>
|
||||
</span>
|
||||
<span className="magic-note-list-item__preview">
|
||||
{note.preview || t('notes.noPreview')}
|
||||
</span>
|
||||
{!detailView && <MagicNoteTagChips tags={note.tags ?? []} />}
|
||||
<span className={detailView ? 'sr-only' : 'magic-note-list-item__meta magic-note-list-item__meta--overview'}>
|
||||
{t(
|
||||
note.entryCount === 1
|
||||
? 'notes.entryCountOne'
|
||||
: 'notes.entryCountOther',
|
||||
{ count: note.entryCount }
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-controls={`magic-note-actions-${note.id}`}
|
||||
aria-expanded={noteActionsId === note.id}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('actions.more', { title: note.title })}
|
||||
title={t('actions.more', { title: note.title })}
|
||||
className="magic-note-more icon-button"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
noteActionTriggerRef.current = event.currentTarget
|
||||
setDeletingNote(false)
|
||||
setNoteActionsId((current) => current === note.id ? '' : note.id)
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="magic-notes-page">
|
||||
<PageHeader
|
||||
@@ -2377,7 +2459,15 @@ export function MagicNotesWorkspace({
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<div className="magic-notes-list magic-notes-card-grid">
|
||||
<div
|
||||
ref={noteGridRef}
|
||||
className={`magic-notes-list magic-notes-card-grid${noteWindow.windowed ? ' magic-notes-card-grid--windowed' : ''}`}
|
||||
role={noteWindow.windowed ? 'list' : undefined}
|
||||
aria-label={noteWindow.windowed ? t('notes.listLabel') : undefined}
|
||||
onBlur={noteWindow.onBlur}
|
||||
onFocus={noteWindow.onFocus}
|
||||
onScroll={noteWindow.onScroll}
|
||||
>
|
||||
{loadStatus === 'loading' ? (
|
||||
<p className="magic-notes-muted">
|
||||
{t('status.loadingNotes')}
|
||||
@@ -2400,60 +2490,13 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
visibleNotes.map((note) => (
|
||||
<div className="magic-note-row" key={note.id}>
|
||||
<button
|
||||
id={`magic-note-select-${note.id}`}
|
||||
className="magic-note-list-item"
|
||||
aria-current={selectedNoteId === note.id ? 'true' : undefined}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selectedNoteId !== note.id && requestDraftSwitch({
|
||||
kind: 'note',
|
||||
noteId: note.id
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="magic-note-list-item__title">
|
||||
{note.pinned && (
|
||||
<Pin aria-label={t('status.pinned')} size={12} />
|
||||
)}
|
||||
<span className="magic-note-list-item__title-text">{note.title}</span>
|
||||
<time dateTime={note.updatedAt} title={t('notes.updatedAt', { date: dateFormatter.format(new Date(note.updatedAt)) })}>
|
||||
{relativeDate(note.updatedAt)}
|
||||
</time>
|
||||
</span>
|
||||
<span className="magic-note-list-item__preview">
|
||||
{note.preview || t('notes.noPreview')}
|
||||
</span>
|
||||
{!detailView && <MagicNoteTagChips tags={note.tags ?? []} />}
|
||||
<span className={detailView ? 'sr-only' : 'magic-note-list-item__meta magic-note-list-item__meta--overview'}>
|
||||
{t(
|
||||
note.entryCount === 1
|
||||
? 'notes.entryCountOne'
|
||||
: 'notes.entryCountOther',
|
||||
{ count: note.entryCount }
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-controls={`magic-note-actions-${note.id}`}
|
||||
aria-expanded={noteActionsId === note.id}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('actions.more', { title: note.title })}
|
||||
title={t('actions.more', { title: note.title })}
|
||||
className="magic-note-more icon-button"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
noteActionTriggerRef.current = event.currentTarget
|
||||
setDeletingNote(false)
|
||||
setNoteActionsId((current) => current === note.id ? '' : note.id)
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" size={14} />
|
||||
</button>
|
||||
noteWindow.windowed ? noteWindow.segments.map((segment) => segment.kind === 'spacer' ? (
|
||||
<div aria-hidden="true" className="magic-notes-list__spacer" key={`spacer:${segment.key}`} style={{ height: `${segment.height}px` }} />
|
||||
) : (
|
||||
<div className="magic-notes-window-row" data-magic-note-window-row={segment.key} key={segment.key} ref={noteWindow.rowRef(segment.key)} role="none" style={{ paddingBottom: `${noteWindow.gap}px` }}>
|
||||
{visibleNotes.slice(segment.start, segment.end).map((note, offset) => renderNoteCard(note, segment.start + offset))}
|
||||
</div>
|
||||
))
|
||||
)) : visibleNotes.map((note, index) => renderNoteCard(note, index))
|
||||
)}
|
||||
{actionNote && createPortal(
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from './i18n'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import type { MagicNoteDetail, MagicNoteSummary } from '../../shared/magic-notes-contracts'
|
||||
import { installFakeListLayout, type FakeListLayout } from './list-window-test-layout'
|
||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||
import { defaultMaxRenderedRows } from './use-list-window'
|
||||
|
||||
vi.mock('./MagicNoteEditor', () => ({ MagicNoteEditor: () => <div data-testid="magic-note-editor" /> }))
|
||||
vi.mock('./MagicCanvasThumbnail', () => ({ MagicCanvasThumbnail: () => null }))
|
||||
vi.mock('./MagicNoteContent', () => ({ MagicNoteContent: () => <div /> }))
|
||||
vi.mock('./MagicCanvasEditor', async () => {
|
||||
const model = await import('./magic-canvas/model')
|
||||
return { ...model, MagicCanvasEditor: () => null }
|
||||
})
|
||||
|
||||
const count = 2_000
|
||||
const rowHeight = 120
|
||||
const idOf = (index: number): string => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`
|
||||
const titleOf = (index: number): string => `Note ${String(index).padStart(4, '0')}`
|
||||
const notes: MagicNoteSummary[] = Array.from({ length: count }, (_, index) => ({
|
||||
id: idOf(index), title: titleOf(index), preview: `preview ${index}`, entryCount: 1, pinned: false, tags: [],
|
||||
revision: 1, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}))
|
||||
const detailOf = (id: string): MagicNoteDetail => ({ ...notes.find((note) => note.id === id)!, entries: [] })
|
||||
|
||||
let layout: FakeListLayout
|
||||
const grid = (): HTMLElement => document.querySelector<HTMLElement>('.magic-notes-card-grid')!
|
||||
const cards = (): string[] => [...grid().querySelectorAll<HTMLElement>('.magic-note-list-item')].map((card) => card.id.replace('magic-note-select-', ''))
|
||||
const card = (index: number): HTMLElement | null => document.getElementById(`magic-note-select-${idOf(index)}`)
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
localStorage.clear()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() })
|
||||
layout = installFakeListLayout({
|
||||
isContainer: (element) => element.classList.contains('magic-notes-card-grid'),
|
||||
rowAttribute: 'data-magic-note-window-row',
|
||||
rowHeight: () => rowHeight
|
||||
})
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
|
||||
magicNotes: {
|
||||
list: vi.fn(async () => ({ notes, tags: [] })),
|
||||
listTodos: vi.fn(async () => ({ todos: [] })),
|
||||
get: vi.fn(async (id: string) => detailOf(id)),
|
||||
onChanged: () => vi.fn(),
|
||||
onAnalysisEvent: () => vi.fn()
|
||||
},
|
||||
updates: { getSettings: vi.fn(async () => undefined) },
|
||||
settings: { getRuntime: vi.fn() }
|
||||
} as unknown as DesktopApi })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
layout.restore()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
render(<MagicNotesWorkspace onNotify={vi.fn()} />)
|
||||
await screen.findByText(titleOf(0))
|
||||
layout.measure()
|
||||
}
|
||||
|
||||
describe('MagicNotesWorkspace note list windowing', () => {
|
||||
it('keeps the DOM bounded for 2,000 notes with list semantics and the full scroll height', async () => {
|
||||
await setup()
|
||||
expect(grid()).toHaveClass('magic-notes-card-grid--windowed')
|
||||
expect(grid()).toHaveAttribute('role', 'list')
|
||||
expect(cards().length).toBeGreaterThan(0)
|
||||
expect(cards().length).toBeLessThanOrEqual(30)
|
||||
expect(cards()[0]).toBe(idOf(0))
|
||||
expect(layout.contentHeight(grid())).toBe(count * rowHeight)
|
||||
const first = card(0)!.closest('.magic-note-row')!
|
||||
expect(first).toHaveAttribute('role', 'listitem')
|
||||
expect(first).toHaveAttribute('aria-posinset', '1')
|
||||
expect(first).toHaveAttribute('aria-setsize', String(count))
|
||||
})
|
||||
|
||||
it('mounts the notes at the scroll position', async () => {
|
||||
await setup()
|
||||
layout.scroll(grid(), 1_500 * rowHeight)
|
||||
expect(cards().length).toBeLessThanOrEqual(defaultMaxRenderedRows)
|
||||
expect(card(1_500)).toBeInTheDocument()
|
||||
expect(card(1_500)!.getBoundingClientRect().top).toBe(0)
|
||||
expect(card(1_500)!.closest('.magic-note-row')).toHaveAttribute('aria-posinset', '1501')
|
||||
expect(card(0)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the selected and menu-open notes mounted at their real position and returns focus to the selected card', async () => {
|
||||
await setup()
|
||||
layout.scroll(grid(), 1_500 * rowHeight)
|
||||
fireEvent.click(card(1_500)!)
|
||||
await screen.findByDisplayValue(titleOf(1_500))
|
||||
layout.measure()
|
||||
// The split list beside the open note: the selected card stays mounted.
|
||||
layout.scroll(grid(), 0)
|
||||
expect(card(0)).toBeInTheDocument()
|
||||
expect(card(1_500)).toHaveAttribute('aria-current', 'true')
|
||||
const order = cards().map((id) => Number(id.slice(-12)))
|
||||
expect(order).toEqual([...order].sort((left, right) => left - right))
|
||||
// It sits where it belongs, so scrolling it into view lands on it.
|
||||
const offset = card(1_500)!.getBoundingClientRect().top + grid().scrollTop
|
||||
expect(offset).toBe(1_500 * rowHeight)
|
||||
layout.scroll(grid(), offset)
|
||||
expect(card(1_500)!.getBoundingClientRect().top).toBe(0)
|
||||
expect(card(1_501)).toBeInTheDocument()
|
||||
|
||||
layout.scroll(grid(), 0)
|
||||
fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${titleOf(3)}` }))
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
layout.scroll(grid(), 900 * rowHeight)
|
||||
expect(card(3)).toBeInTheDocument()
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回总览' }))
|
||||
await waitFor(() => expect(card(1_500)).toHaveFocus())
|
||||
})
|
||||
|
||||
it('re-windows the search results at the clamped scroll position, as the browser leaves it', async () => {
|
||||
await setup()
|
||||
layout.scroll(grid(), 1_000 * rowHeight)
|
||||
fireEvent.change(screen.getByPlaceholderText(/搜索/), { target: { value: 'Note 19' } })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
layout.measure()
|
||||
// 100 matches (Note 1900-1999) stay windowed; the list does not jump to
|
||||
// the top (it never did), so the end of the shorter result is in view.
|
||||
const shown = cards().map((id) => Number(id.slice(-12)))
|
||||
expect(shown.every((index) => index >= 1_900)).toBe(true)
|
||||
expect(card(1_999)).toBeInTheDocument()
|
||||
expect(card(1_999)!.closest('.magic-note-row')).toHaveAttribute('aria-setsize', '100')
|
||||
expect(card(1_999)!.closest('.magic-note-row')).toHaveAttribute('aria-posinset', '100')
|
||||
layout.scroll(grid(), 0)
|
||||
expect(cards()[0]).toBe(idOf(1_900))
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,7 @@ import { createPortal } from 'react-dom'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { WorkspaceActionDialog } from './WorkspaceActionDialog'
|
||||
import { WorkspaceGitTools } from './WorkspaceGitTools'
|
||||
import { useListWindow } from './use-list-window'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
@@ -72,6 +73,42 @@ function changedTree(entries: { path: string; row: React.JSX.Element }[], prefix
|
||||
return <>{[...groups].map(([name, children]) => <details key={name} open><summary>{name}</summary><div className="workspace-files__children">{changedTree(children, `${prefix}${name}/`)}</div></details>)}{files.map((entry) => entry.row)}</>
|
||||
}
|
||||
|
||||
export type FileTreeRow =
|
||||
| { kind: 'entry'; key: string; depth: number; entry: WorkspaceDirectoryEntry }
|
||||
| { kind: 'status'; key: string; depth: number; path: string; status: 'loading' | 'error' | 'truncated' }
|
||||
|
||||
export function fileRowKey(path: string): string {
|
||||
return `entry:${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible tree in display order: each expanded directory is followed by
|
||||
* its children, then its reading / error / truncated notes, as before.
|
||||
*/
|
||||
export function flattenFileTree(
|
||||
entries: readonly WorkspaceDirectoryEntry[],
|
||||
listings: Readonly<Record<string, WorkspaceDirectoryListing>>,
|
||||
expandedPaths: ReadonlySet<string>,
|
||||
loadingPaths: ReadonlySet<string>,
|
||||
errors: Readonly<Record<string, string | undefined>>
|
||||
): FileTreeRow[] {
|
||||
const rows: FileTreeRow[] = []
|
||||
const visit = (items: readonly WorkspaceDirectoryEntry[], depth: number): void => {
|
||||
for (const entry of items) {
|
||||
rows.push({ kind: 'entry', key: fileRowKey(entry.path), depth, entry })
|
||||
if (entry.type !== 'directory' || !expandedPaths.has(entry.path)) continue
|
||||
const listing = listings[entry.path]
|
||||
const childDepth = depth + 1
|
||||
if (listing) visit(listing.entries, childDepth)
|
||||
if (loadingPaths.has(entry.path)) rows.push({ kind: 'status', key: `loading:${entry.path}`, depth: childDepth, path: entry.path, status: 'loading' })
|
||||
if (errors[entry.path]) rows.push({ kind: 'status', key: `error:${entry.path}`, depth: childDepth, path: entry.path, status: 'error' })
|
||||
if (listing?.truncated) rows.push({ kind: 'status', key: `truncated:${entry.path}`, depth: childDepth, path: entry.path, status: 'truncated' })
|
||||
}
|
||||
}
|
||||
visit(entries, 0)
|
||||
return rows
|
||||
}
|
||||
|
||||
export function WorkspaceFilesPanel({
|
||||
projectId,
|
||||
rootPath,
|
||||
@@ -150,6 +187,7 @@ export function WorkspaceFilesPanel({
|
||||
const activeView = showGit ? view : 'files'
|
||||
const [selected, setSelected] = useState<{ projectId?: string; path: string }>()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const treeRef = useRef<HTMLDivElement>(null)
|
||||
const refreshRef = useRef<HTMLButtonElement>(null)
|
||||
const backRef = useRef<HTMLButtonElement>(null)
|
||||
const returnContext = useRef<{ scrollTop: number; scrollLeft: number; trigger: HTMLButtonElement } | undefined>(undefined)
|
||||
@@ -287,8 +325,9 @@ export function WorkspaceFilesPanel({
|
||||
[changedFiles]
|
||||
)
|
||||
const emptyPaths = useMemo(() => new Set<string>(), [])
|
||||
const listings =
|
||||
listingState.projectId === projectId ? listingState.value : {}
|
||||
const emptyRecord = useMemo(() => ({}), [])
|
||||
const listings: Record<string, WorkspaceDirectoryListing> =
|
||||
listingState.projectId === projectId ? listingState.value : emptyRecord
|
||||
const expandedPaths =
|
||||
expandedState.projectId === projectId
|
||||
? expandedState.value
|
||||
@@ -297,7 +336,7 @@ export function WorkspaceFilesPanel({
|
||||
loadingState.projectId === projectId
|
||||
? loadingState.value
|
||||
: emptyPaths
|
||||
const errors = errorState.projectId === projectId ? errorState.value : {}
|
||||
const errors: Record<string, string | undefined> = errorState.projectId === projectId ? errorState.value : emptyRecord
|
||||
const renderDirectoryError = (path: string): React.JSX.Element | null => errors[path] ? (
|
||||
<p className="workspace-files__error" role="alert">
|
||||
{errors[path]}
|
||||
@@ -368,55 +407,50 @@ export function WorkspaceFilesPanel({
|
||||
}}><MoreHorizontal size={14} aria-hidden="true" /></button>
|
||||
)
|
||||
|
||||
const renderEntry = (
|
||||
entry: WorkspaceDirectoryEntry
|
||||
const renderTreeRow = (
|
||||
row: FileTreeRow,
|
||||
rowProps: Record<string, unknown>
|
||||
): React.JSX.Element => {
|
||||
const expanded = expandedPaths.has(entry.path)
|
||||
const listing = listings[entry.path]
|
||||
const changed = changedByPath.get(entry.path)
|
||||
if (entry.type === 'directory') {
|
||||
// Nesting is drawn by indentation; the rows themselves are flat, so they
|
||||
// keep their identity whether or not the tree is windowed.
|
||||
const indent = row.depth ? { marginLeft: `calc(${row.depth} * var(--space-4))` } : undefined
|
||||
if (row.kind === 'status') {
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<div className="workspace-files__entry">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="workspace-files__row"
|
||||
onClick={() => toggleDirectory(entry.path)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={13} />
|
||||
) : (
|
||||
<ChevronRight size={13} />
|
||||
)}
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
{entryMenu(entry)}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="workspace-files__children">
|
||||
{listing?.entries.map((child) =>
|
||||
renderEntry(child)
|
||||
)}
|
||||
{loadingPaths.has(entry.path) && (
|
||||
<p className="workspace-files__status">
|
||||
{t('files.reading')}
|
||||
</p>
|
||||
)}
|
||||
{renderDirectoryError(entry.path)}
|
||||
{listing?.truncated && (
|
||||
<p className="workspace-files__status">
|
||||
{t('files.directoryTruncated')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div key={row.key} style={indent} {...rowProps}>
|
||||
{row.status === 'error' ? renderDirectoryError(row.path) : (
|
||||
<p className="workspace-files__status">
|
||||
{t(row.status === 'loading' ? 'files.reading' : 'files.directoryTruncated')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { entry } = row
|
||||
const expanded = expandedPaths.has(entry.path)
|
||||
const changed = changedByPath.get(entry.path)
|
||||
if (entry.type === 'directory') {
|
||||
return (
|
||||
<div className="workspace-files__entry" key={row.key} style={indent} {...rowProps}>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="workspace-files__row"
|
||||
onClick={() => toggleDirectory(entry.path)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={13} />
|
||||
) : (
|
||||
<ChevronRight size={13} />
|
||||
)}
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
{entryMenu(entry)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="workspace-files__entry" key={entry.path}>
|
||||
<div className="workspace-files__entry" key={row.key} style={indent} {...rowProps}>
|
||||
<button
|
||||
className="workspace-files__row"
|
||||
aria-current={selected?.projectId === projectId && selected?.path === entry.path ? 'true' : undefined}
|
||||
@@ -442,6 +476,32 @@ export function WorkspaceFilesPanel({
|
||||
)
|
||||
}
|
||||
|
||||
const root = listings[browsedPath]
|
||||
const treeRows = useMemo(
|
||||
() => flattenFileTree(root?.entries ?? [], listings, expandedPaths, loadingPaths, errors),
|
||||
[errors, expandedPaths, listings, loadingPaths, root]
|
||||
)
|
||||
const treeRowKeysKey = treeRows.map((row) => row.key).join('\n')
|
||||
const treeRowKeys = useMemo(() => (treeRowKeysKey ? treeRowKeysKey.split('\n') : []), [treeRowKeysKey])
|
||||
const selectedPath = selected && selected.projectId === projectId ? selected.path : undefined
|
||||
// P5: a large workspace (500 entries per directory, many expanded) mounts
|
||||
// only the rows near the viewport. The docked files view is the scroller;
|
||||
// the sidebar body only scrolls where that view is not bounded.
|
||||
const treeWindow = useListWindow({
|
||||
ids: treeRowKeys,
|
||||
scope: `${projectId ?? ''}\n${browsedPath}`,
|
||||
listRef: treeRef,
|
||||
scrollParent: (tree) =>
|
||||
tree.closest<HTMLElement>('.workspace-files__files-view') ?? tree.closest<HTMLElement>('.assistant-sidebar__body'),
|
||||
// The open file, the menu's and the dialog's entry keep their rows (focus returns there).
|
||||
keepIds: [selectedPath, menu?.entry.path, dialog?.path].map((path) => (path === undefined ? undefined : fileRowKey(path))),
|
||||
// Stays on while the Git view or a diff hides the tree, so hiding it never mounts every row.
|
||||
enabled: true,
|
||||
active: activeView === 'files' && !showingDiff,
|
||||
estimatedRowHeight: 32,
|
||||
rowAttribute: 'data-workspace-file-row'
|
||||
})
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<p className="assistant-sidebar__empty">
|
||||
@@ -450,7 +510,6 @@ export function WorkspaceFilesPanel({
|
||||
)
|
||||
}
|
||||
|
||||
const root = listings[browsedPath]
|
||||
const rootName = rootPath?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) || t('files.currentWorkspace')
|
||||
const renderPatch = (patch: string): React.JSX.Element => (
|
||||
<pre className="assistant-sidebar__diff workspace-files__diff">
|
||||
@@ -562,8 +621,16 @@ export function WorkspaceFilesPanel({
|
||||
</p>
|
||||
) : errors[browsedPath] && !root ? null : root?.entries.length ? (
|
||||
<>
|
||||
<div className="workspace-files__tree">
|
||||
{root.entries.map((entry) => renderEntry(entry))}
|
||||
<div ref={treeRef} className="workspace-files__tree" onBlur={treeWindow.onBlur} onFocus={treeWindow.onFocus}>
|
||||
{treeWindow.segments.map((segment) => {
|
||||
if (segment.kind === 'spacer') {
|
||||
return <div aria-hidden="true" className="workspace-files__spacer" key={`spacer:${segment.key}`} style={{ height: `${segment.height}px` }} />
|
||||
}
|
||||
const row = treeRows[segment.index]!
|
||||
return renderTreeRow(row, treeWindow.windowed
|
||||
? { 'data-workspace-file-row': row.key, ref: treeWindow.rowRef(row.key) }
|
||||
: {})
|
||||
})}
|
||||
</div>
|
||||
{root.truncated && (
|
||||
<p className="workspace-files__status">
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import { installFakeListLayout, type FakeListLayout } from './list-window-test-layout'
|
||||
import { defaultMaxRenderedRows } from './use-list-window'
|
||||
import { flattenFileTree, WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
import type { WorkspaceDirectoryListing } from '../../shared/assistant-contracts'
|
||||
|
||||
// 4 directories x 500 files (the per-directory listing cap) = 2,004 rows when all are expanded.
|
||||
const directories = ['a', 'b', 'c', 'd']
|
||||
const filesPer = 500
|
||||
const fileName = (directory: string, index: number): string => `${directory}-${String(index).padStart(3, '0')}.txt`
|
||||
const listing = (path: string): WorkspaceDirectoryListing => ({
|
||||
path, truncated: false,
|
||||
entries: path
|
||||
? Array.from({ length: filesPer }, (_, index) => ({ name: fileName(path, index), path: `${path}/${fileName(path, index)}`, type: 'file' as const }))
|
||||
: directories.map((name) => ({ name, path: name, type: 'directory' as const }))
|
||||
})
|
||||
|
||||
const rowHeight = 32
|
||||
let layout: FakeListLayout
|
||||
// The docked files view is the tree's scroller (see workspace-files-layout.electron.test.ts).
|
||||
const body = (): HTMLElement => document.querySelector<HTMLElement>('.workspace-files__files-view')!
|
||||
const tree = (): HTMLElement => document.querySelector<HTMLElement>('.workspace-files__tree')!
|
||||
const rows = (): string[] => [...tree().querySelectorAll<HTMLElement>('.workspace-files__row')].map((row) => row.textContent ?? '')
|
||||
const fileRow = (directory: string, index: number): HTMLElement | null =>
|
||||
tree().querySelector<HTMLElement>(`.workspace-files__row[title="${directory}/${fileName(directory, index)}"]`)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('goodbuddy', { workspace: { manage: vi.fn(async () => ({ kind: 'branches', current: 'main', branches: [] })) } })
|
||||
layout = installFakeListLayout({
|
||||
isContainer: (element) => element.classList.contains('workspace-files__files-view'),
|
||||
rowAttribute: 'data-workspace-file-row',
|
||||
rowHeight: () => rowHeight
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
layout.restore()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
await changeUiLocale('zh-CN')
|
||||
})
|
||||
|
||||
async function setup(onOpenFile = vi.fn()) {
|
||||
const onListDirectory = vi.fn(async (path: string) => listing(path))
|
||||
const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile }
|
||||
const view = render(<div className="assistant-sidebar__body"><section><WorkspaceFilesPanel {...props} /></section></div>)
|
||||
// Bottom-up: expanding a directory pushes the later ones out of the window.
|
||||
for (const directory of [...directories].reverse()) {
|
||||
fireEvent.click(await screen.findByRole('button', { name: directory }))
|
||||
await screen.findByText(fileName(directory, 0))
|
||||
layout.measure()
|
||||
}
|
||||
return { ...view, onListDirectory, props }
|
||||
}
|
||||
|
||||
describe('WorkspaceFilesPanel tree windowing', () => {
|
||||
it('flattens expanded directories in display order with their status rows', () => {
|
||||
const listings = { '': listing(''), a: { ...listing('a'), truncated: true } }
|
||||
const flat = flattenFileTree(listings[''].entries, listings, new Set(['a', 'b']), new Set(['b']), { c: 'boom' })
|
||||
expect(flat.slice(0, 2).map((row) => row.key)).toEqual(['entry:a', 'entry:a/a-000.txt'])
|
||||
expect(flat[1]!.depth).toBe(1)
|
||||
expect(flat.slice(filesPer + 1, filesPer + 4).map((row) => row.key)).toEqual(['truncated:a', 'entry:b', 'loading:b'])
|
||||
// Errors show only for expanded directories, as before.
|
||||
expect(flat.some((row) => row.key === 'error:c')).toBe(false)
|
||||
expect(flat).toHaveLength(1 + filesPer + 1 + 1 + 1 + 2)
|
||||
})
|
||||
|
||||
it('keeps the DOM bounded for 2,004 expanded rows and follows the files view scroll', async () => {
|
||||
await setup()
|
||||
const mounted = rows()
|
||||
expect(mounted.length).toBeGreaterThan(0)
|
||||
expect(mounted.length).toBeLessThanOrEqual(defaultMaxRenderedRows)
|
||||
expect(tree().getBoundingClientRect().height).toBe((directories.length * (filesPer + 1)) * rowHeight)
|
||||
// Row 1,002 is c/c-000 (a, 500 files, b, 500 files, c).
|
||||
layout.scroll(body(), 1_003 * rowHeight)
|
||||
expect(fileRow('c', 1)).toBeInTheDocument()
|
||||
expect(fileRow('a', 0)).toBeNull()
|
||||
expect(rows().length).toBeLessThanOrEqual(defaultMaxRenderedRows)
|
||||
// Indentation still shows the nesting.
|
||||
expect(fileRow('c', 1)!.parentElement!.style.marginLeft).toContain('1 *')
|
||||
}, 20_000)
|
||||
|
||||
it('keeps the selected file and the open menu mounted, and scrolls back to the selection', async () => {
|
||||
const onOpenFile = vi.fn()
|
||||
await setup(onOpenFile)
|
||||
layout.scroll(body(), 1_600 * rowHeight)
|
||||
const target = fileRow('d', 97)!
|
||||
fireEvent.click(target)
|
||||
expect(onOpenFile).toHaveBeenCalledWith('d/d-097.txt')
|
||||
expect(target).toHaveAttribute('aria-current', 'true')
|
||||
layout.scroll(body(), 0)
|
||||
expect(fileRow('a', 0)).toBeInTheDocument()
|
||||
expect(fileRow('d', 97)).toBe(target)
|
||||
const offset = target.getBoundingClientRect().top + body().scrollTop
|
||||
expect(offset).toBe((3 * (filesPer + 1) + 1 + 97) * rowHeight)
|
||||
layout.scroll(body(), offset)
|
||||
expect(target.getBoundingClientRect().top).toBe(0)
|
||||
|
||||
layout.scroll(body(), 0)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'a-002.txt 的更多操作' }))
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
layout.scroll(body(), 1_000 * rowHeight)
|
||||
expect(fileRow('a', 2)).toBeInTheDocument()
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'a-002.txt 的更多操作' })).toHaveFocus())
|
||||
}, 20_000)
|
||||
|
||||
it('collapses and expands directories in the windowed tree', async () => {
|
||||
await setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'a' }))
|
||||
expect(screen.getByRole('button', { name: 'a' })).toHaveAttribute('aria-expanded', 'false')
|
||||
layout.measure()
|
||||
expect(fileRow('a', 0)).toBeNull()
|
||||
expect(fileRow('b', 0)).toBeInTheDocument()
|
||||
expect(tree().getBoundingClientRect().height).toBe((directories.length * (filesPer + 1) - filesPer) * rowHeight)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'a' }))
|
||||
await screen.findByText(fileName('a', 0))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(fileRow('a', 0)).toBeInTheDocument()
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { RemoveSourceDialog } from './RemoveSourceDialog'
|
||||
import { KnowledgeActionsMenu, type KnowledgeAction } from './KnowledgeActionsMenu'
|
||||
import { useListWindow } from '../use-list-window'
|
||||
import { ListWindowTableSpacer } from '../ListWindowSpacer'
|
||||
|
||||
const sourceStatusLabelKeys = {
|
||||
queued: 'sourceStatuses.queued',
|
||||
@@ -138,6 +140,8 @@ export function DocumentsView({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [viewedDocumentId, setViewedDocumentId] = useState<string>()
|
||||
const previewBackRef = useRef<HTMLButtonElement>(null)
|
||||
const tableBodyRef = useRef<HTMLTableSectionElement>(null)
|
||||
const [previewReturnKey, setPreviewReturnKey] = useState<string>()
|
||||
const previewOriginRef = useRef<{ trigger: HTMLElement; scroll?: HTMLElement; top: number } | undefined>(undefined)
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
@@ -218,6 +222,22 @@ export function DocumentsView({
|
||||
}
|
||||
return result
|
||||
}, [documents, sources, locale, query])
|
||||
const rowKeysKey = rows.map((row) => row.key).join('\n')
|
||||
const rowKeys = useMemo(() => (rowKeysKey ? rowKeysKey.split('\n') : []), [rowKeysKey])
|
||||
// P5: large libraries mount only the table rows near the page viewport. The
|
||||
// page (`.workspace-panel-scroll`) scrolls, not the table.
|
||||
const documentWindow = useListWindow({
|
||||
ids: rowKeys,
|
||||
scope: `${library.id}\n${query}`,
|
||||
listRef: tableBodyRef,
|
||||
scrollParent: (body) => body.closest<HTMLElement>('.workspace-panel-scroll'),
|
||||
// The previewed document's row is where focus returns after the preview.
|
||||
keepIds: [previewReturnKey],
|
||||
enabled: true,
|
||||
active: !viewedDocumentId,
|
||||
estimatedRowHeight: 96,
|
||||
rowAttribute: 'data-knowledge-document-row'
|
||||
})
|
||||
const viewedDocument = useMemo(() => documents.find((document) => document.id === viewedDocumentId), [documents, viewedDocumentId])
|
||||
const viewedSource = sources.find((source) => source.id === viewedDocument?.sourceId)
|
||||
const viewedTaskSourceId = viewedSource && viewedSource.kind !== 'directory' &&
|
||||
@@ -500,9 +520,9 @@ export function DocumentsView({
|
||||
</div>
|
||||
) : (
|
||||
<div className="knowledge-documents__table-scroll">
|
||||
<table aria-label={t('documents.table.title')}>
|
||||
<table aria-label={t('documents.table.title')} aria-rowcount={documentWindow.windowed ? rows.length + 1 : undefined}>
|
||||
<thead>
|
||||
<tr>
|
||||
<tr aria-rowindex={documentWindow.windowed ? 1 : undefined}>
|
||||
<th>
|
||||
{t('documents.table.columns.document')}
|
||||
</th>
|
||||
@@ -520,10 +540,18 @@ export function DocumentsView({
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ key, source, document, grouped }) => {
|
||||
<tbody ref={tableBodyRef} onBlur={documentWindow.onBlur} onFocus={documentWindow.onFocus}>
|
||||
{documentWindow.segments.map((segment) => {
|
||||
if (segment.kind === 'spacer') return (
|
||||
<ListWindowTableSpacer className="knowledge-documents__spacer" columns={5} height={segment.height} key={`spacer:${segment.key}`} />
|
||||
)
|
||||
const { key, source, document, grouped } = rows[segment.index]!
|
||||
// The header row is row 1.
|
||||
const rowProps = documentWindow.windowed
|
||||
? { 'aria-rowindex': segment.index + 2, 'data-knowledge-document-row': key, ref: documentWindow.rowRef(key) }
|
||||
: {}
|
||||
if (!document && source) return (
|
||||
<tr key={key} className="knowledge-source-group">
|
||||
<tr key={key} className="knowledge-source-group" {...rowProps}>
|
||||
<td colSpan={5}>
|
||||
<div className="knowledge-source-row">
|
||||
<div className="knowledge-source-row__main">
|
||||
@@ -563,7 +591,7 @@ export function DocumentsView({
|
||||
(task.status === 'queued' || task.status === 'running')
|
||||
)
|
||||
return (
|
||||
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined}>
|
||||
<tr key={key} className={grouped ? 'knowledge-document-row--grouped' : undefined} {...rowProps}>
|
||||
<td>
|
||||
<strong>{document.name}</strong>
|
||||
{(document.path || (source?.kind === 'url' && source.location)) && (
|
||||
@@ -640,6 +668,7 @@ export function DocumentsView({
|
||||
{document.resultId && <button type="button" className="secondary-button" onClick={(event) => {
|
||||
const scroll = event.currentTarget.closest<HTMLElement>('.workspace-panel-scroll') ?? undefined
|
||||
previewOriginRef.current = { trigger: event.currentTarget, scroll, top: scroll?.scrollTop ?? 0 }
|
||||
setPreviewReturnKey(key)
|
||||
setViewedDocumentId(document.id)
|
||||
requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 })
|
||||
}}>{t('documents.viewResult')}</button>}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from '../i18n'
|
||||
import { defaultKnowledgeOntologySettings } from '../../../shared/knowledge-ontology'
|
||||
import { installFakeListLayout, type FakeListLayout } from '../list-window-test-layout'
|
||||
import { defaultMaxRenderedRows } from '../use-list-window'
|
||||
import { DocumentsView } from './DocumentsView'
|
||||
import type { KnowledgeDocumentItem, KnowledgeLibrary } from './types'
|
||||
|
||||
vi.mock('../DocumentResultPreview', () => ({
|
||||
DocumentResultPreview: ({ resultId }: { resultId: string }) => <div>Preview {resultId}</div>
|
||||
}))
|
||||
|
||||
const count = 2_000
|
||||
const rowHeight = 60
|
||||
const library: KnowledgeLibrary = {
|
||||
id: 'library-1', name: 'Docs', description: '', storageMode: 'managed', graphEnabled: false,
|
||||
graphStrategy: 'hybrid', sourceCount: 0, documentCount: count, indexedDocumentCount: count,
|
||||
ontologySettings: defaultKnowledgeOntologySettings, updatedAt: '2026-07-30T08:00:00.000Z'
|
||||
}
|
||||
const nameOf = (index: number): string => `doc-${String(index).padStart(4, '0')}.md`
|
||||
const documents: KnowledgeDocumentItem[] = Array.from({ length: count }, (_, index) => ({
|
||||
id: `d${index}`, libraryId: library.id, name: nameOf(index), status: 'ready', indexProgress: 100,
|
||||
chunkCount: 1, size: 10, resultId: `result-${index}`
|
||||
}))
|
||||
|
||||
let layout: FakeListLayout
|
||||
let animationFrames: FrameRequestCallback[] = []
|
||||
const page = (): HTMLElement => document.querySelector<HTMLElement>('.workspace-panel-scroll')!
|
||||
const table = (): HTMLElement => screen.getByRole('table', { hidden: true })
|
||||
const renderedNames = (): string[] =>
|
||||
[...table().querySelectorAll('tbody tr strong')].map((cell) => cell.textContent ?? '')
|
||||
const row = (index: number): HTMLElement | null =>
|
||||
table().querySelector<HTMLElement>(`tr[data-knowledge-document-row="document:d${index}"]`)
|
||||
const flushFrames = (): void => act(() => {
|
||||
const frames = animationFrames
|
||||
animationFrames = []
|
||||
for (const frame of frames) frame(0)
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
animationFrames = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => animationFrames.push(callback))
|
||||
layout = installFakeListLayout({
|
||||
isContainer: (element) => element.classList.contains('workspace-panel-scroll'),
|
||||
rowAttribute: 'data-knowledge-document-row',
|
||||
rowHeight: () => rowHeight
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
layout.restore()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function setup(): void {
|
||||
render(
|
||||
<div className="workspace-panel-scroll">
|
||||
<DocumentsView
|
||||
documents={documents} library={library} sources={[]} tasks={[]}
|
||||
onImportDirectory={vi.fn()} onImportFiles={vi.fn()} onImportUrl={vi.fn()}
|
||||
onOpenDocumentSource={vi.fn()} onPauseSource={vi.fn()} onRemoveSource={vi.fn()}
|
||||
onRebuildDocument={vi.fn()} onRetrySource={vi.fn()} onSyncSource={vi.fn()}
|
||||
onManageChunks={vi.fn()} onViewTasks={vi.fn()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
layout.measure()
|
||||
}
|
||||
|
||||
describe('DocumentsView windowing', () => {
|
||||
it('keeps the table bounded for 2,000 documents with row indexes for assistive technology', () => {
|
||||
setup()
|
||||
const names = renderedNames()
|
||||
expect(names.length).toBeGreaterThan(0)
|
||||
expect(names.length).toBeLessThanOrEqual(30)
|
||||
expect(names[0]).toBe(nameOf(0))
|
||||
expect(table()).toHaveAttribute('aria-rowcount', String(count + 1))
|
||||
expect(row(0)).toHaveAttribute('aria-rowindex', '2')
|
||||
// Spacers keep the page as tall as the full table.
|
||||
const body = table().querySelector('tbody')!
|
||||
expect(body.getBoundingClientRect().height).toBe(count * rowHeight)
|
||||
})
|
||||
|
||||
it('follows the page scroll, which belongs to an ancestor of the table', () => {
|
||||
setup()
|
||||
layout.scroll(page(), 1_200 * rowHeight)
|
||||
expect(renderedNames().length).toBeLessThanOrEqual(defaultMaxRenderedRows)
|
||||
expect(row(1_200)).toBeInTheDocument()
|
||||
expect(row(1_200)!.getBoundingClientRect().top).toBe(0)
|
||||
expect(row(1_200)).toHaveAttribute('aria-rowindex', '1202')
|
||||
expect(row(0)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns focus and scroll to the previewed row after the page left it', async () => {
|
||||
setup()
|
||||
layout.scroll(page(), 1_200 * rowHeight)
|
||||
const trigger = within(row(1_200)!).getByRole('button', { name: '查看解析结果' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
flushFrames()
|
||||
expect(screen.getByText('Preview result-1200')).toBeInTheDocument()
|
||||
expect(page().scrollTop).toBe(0)
|
||||
layout.measure()
|
||||
// The hidden list keeps its window; the previewed row stays mounted.
|
||||
expect(row(1_200)).toBe(trigger.closest('tr'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回文档列表' }))
|
||||
flushFrames()
|
||||
// Browsers report the restored scrollTop with a scroll event.
|
||||
act(() => { page().dispatchEvent(new Event('scroll')) })
|
||||
layout.measure()
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(page().scrollTop).toBe(1_200 * rowHeight)
|
||||
expect(row(1_200)!.getBoundingClientRect().top).toBe(0)
|
||||
expect(row(1_201)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders every row without spacers for small libraries and searches', () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'doc-000' } })
|
||||
layout.measure()
|
||||
// doc-0000 .. doc-0009: below the threshold.
|
||||
expect(renderedNames()).toHaveLength(10)
|
||||
expect(table().querySelector('.knowledge-documents__spacer')).toBeNull()
|
||||
expect(table()).not.toHaveAttribute('aria-rowcount')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* A tiny block-flow layout for windowed-list tests (jsdom has none).
|
||||
*
|
||||
* One scroll container (matched by `isContainer`) sits at viewport top 0 and
|
||||
* is `viewport` px tall. Inside it every element stacks its children
|
||||
* vertically: rows (elements carrying `rowAttribute`) take `rowHeight(id)`,
|
||||
* spacers take their inline height (on themselves or their only cell), and
|
||||
* every other element is as tall as its children.
|
||||
*/
|
||||
export type FakeListLayout = {
|
||||
measure: () => void
|
||||
scroll: (container: HTMLElement, top: number) => void
|
||||
contentHeight: (container: HTMLElement) => number
|
||||
restore: () => void
|
||||
}
|
||||
|
||||
export function installFakeListLayout({
|
||||
isContainer,
|
||||
rowAttribute,
|
||||
viewport = 400,
|
||||
rowHeight = () => 40,
|
||||
}: {
|
||||
isContainer: (element: Element) => boolean
|
||||
rowAttribute: string
|
||||
viewport?: number
|
||||
rowHeight?: (id: string) => number
|
||||
}): FakeListLayout {
|
||||
const scrollTops = new WeakMap<Element, number>()
|
||||
|
||||
const ownHeight = (element: HTMLElement): number | undefined => {
|
||||
const id = element.getAttribute(rowAttribute)
|
||||
if (id) return rowHeight(id)
|
||||
const inline = Number.parseFloat(element.style?.height ?? '')
|
||||
if (Number.isFinite(inline)) return inline
|
||||
return undefined
|
||||
}
|
||||
const height = (element: HTMLElement): number => {
|
||||
if (element.hidden) return 0
|
||||
const own = ownHeight(element)
|
||||
if (own !== undefined) return own
|
||||
let sum = 0
|
||||
for (const child of element.children) sum += height(child as HTMLElement)
|
||||
return sum
|
||||
}
|
||||
const containerOf = (element: Element): HTMLElement | null => {
|
||||
let current: Element | null = element
|
||||
while (current && !isContainer(current)) current = current.parentElement
|
||||
return current as HTMLElement | null
|
||||
}
|
||||
const top = (element: HTMLElement, container: HTMLElement): number => {
|
||||
if (element === container) return 0
|
||||
const parent = element.parentElement!
|
||||
let offset = top(parent, container) - (parent === container ? container.scrollTop : 0)
|
||||
for (const sibling of parent.children) {
|
||||
if (sibling === element) break
|
||||
offset += height(sibling as HTMLElement)
|
||||
}
|
||||
return offset
|
||||
}
|
||||
const rect = (y: number, h: number): DOMRect =>
|
||||
({ top: y, bottom: y + h, height: h, left: 0, right: 0, width: 0, x: 0, y, toJSON: () => ({}) }) as DOMRect
|
||||
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const container = containerOf(this)
|
||||
if (!container) return rect(0, 0)
|
||||
if (this === container) return rect(0, viewport)
|
||||
return rect(top(this, container), height(this))
|
||||
})
|
||||
|
||||
const names = ['clientHeight', 'scrollHeight', 'scrollTop'] as const
|
||||
const originals = new Map(names.map((name) => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)]))
|
||||
const contentHeight = (container: HTMLElement): number => {
|
||||
let sum = 0
|
||||
for (const child of container.children) sum += height(child as HTMLElement)
|
||||
return sum
|
||||
}
|
||||
Object.defineProperties(HTMLElement.prototype, {
|
||||
clientHeight: { configurable: true, get(this: HTMLElement) { return isContainer(this) ? viewport : 0 } },
|
||||
scrollHeight: { configurable: true, get(this: HTMLElement) { return isContainer(this) ? contentHeight(this) : 0 } },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) { return scrollTops.get(this) ?? 0 },
|
||||
set(this: HTMLElement, value: number) {
|
||||
const max = isContainer(this) ? Math.max(0, contentHeight(this) - viewport) : 0
|
||||
scrollTops.set(this, Math.min(Math.max(0, value), max))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
class FakeResizeObserver {
|
||||
static instances: FakeResizeObserver[] = []
|
||||
readonly observed = new Set<Element>()
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
FakeResizeObserver.instances.push(this)
|
||||
}
|
||||
observe(element: Element): void { this.observed.add(element) }
|
||||
unobserve(element: Element): void { this.observed.delete(element) }
|
||||
disconnect(): void { this.observed.clear() }
|
||||
fire(): void {
|
||||
const entries = [...this.observed].map((target) => ({ target }) as ResizeObserverEntry)
|
||||
if (entries.length) this.callback(entries, this as unknown as ResizeObserver)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
||||
|
||||
const measure = (): void => {
|
||||
act(() => {
|
||||
for (const observer of [...FakeResizeObserver.instances]) observer.fire()
|
||||
})
|
||||
}
|
||||
return {
|
||||
measure,
|
||||
contentHeight,
|
||||
scroll(container, value) {
|
||||
act(() => {
|
||||
container.scrollTop = value
|
||||
container.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
measure()
|
||||
},
|
||||
restore() {
|
||||
for (const name of names) {
|
||||
const original = originals.get(name)
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, name, original)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -30,3 +30,10 @@
|
||||
.assistant-sidebar__body.assistant-sidebar__body--notes { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
|
||||
.assistant-sidebar__body--notes > .magic-note-panel { flex: 1; }
|
||||
.magic-note-panel .magic-note-panel__footer textarea { min-height: 80px; max-height: 180px; }
|
||||
|
||||
/* Windowed note list (P5): rows of cards span the grid as subgrids; spacers
|
||||
stand in for unmounted rows. Row spacing moves into each row's padding so
|
||||
spacer heights and measured rows add up exactly. */
|
||||
.magic-notes-list.magic-notes-card-grid.magic-notes-card-grid--windowed { row-gap: 0; }
|
||||
.magic-notes-card-grid--windowed > .magic-notes-window-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: stretch; }
|
||||
.magic-notes-card-grid--windowed > .magic-notes-list__spacer { grid-column: 1 / -1; }
|
||||
|
||||
@@ -15302,6 +15302,15 @@ textarea.field-control {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Windowed document table (P5): spacer rows stand in for unmounted rows. */
|
||||
.knowledge-documents__table-scroll tbody tr.knowledge-documents__spacer {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.knowledge-documents__table-scroll tr.knowledge-documents__spacer > td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.knowledge-documents__table-scroll .knowledge-document-metric {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1,97 +1,31 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
defaultEstimatedRowHeight,
|
||||
defaultListWindowThreshold,
|
||||
defaultMaxRenderedRows,
|
||||
listWindowOverscan,
|
||||
listWindowRange,
|
||||
useListWindow,
|
||||
type ListWindowSegment,
|
||||
} from './use-list-window'
|
||||
|
||||
/**
|
||||
* Windowing for the sidebar conversation list (PERF-14).
|
||||
*
|
||||
* Long lists mount only the rows near the viewport; spacers stand in for the
|
||||
* rest, sized from measured row heights (or the mean measured height for rows
|
||||
* never rendered). The list is top-anchored like any normal list. Rows that
|
||||
* hold user state (focus, open menu, rename, the active conversation) stay
|
||||
* mounted at their real position even when scrolled away.
|
||||
* Windowing for the sidebar conversation list (PERF-14), on the shared
|
||||
* list-window hook. The list scrolls itself, is top-anchored, and returns to
|
||||
* the top when the project or search changes. Rows that hold user state
|
||||
* (focus, open menu, rename, the active conversation) stay mounted at their
|
||||
* real position even when scrolled away.
|
||||
*/
|
||||
|
||||
/** Lists shorter than this render every row (no windowing). */
|
||||
export const conversationListWindowThreshold = 60
|
||||
export const conversationListWindowThreshold = defaultListWindowThreshold
|
||||
/** Height assumed for rows before any row has been measured. */
|
||||
export const estimatedConversationRowHeight = 40
|
||||
export const estimatedConversationRowHeight = defaultEstimatedRowHeight
|
||||
/** Upper bound for rows mounted from the range (kept rows come on top). */
|
||||
export const maxRenderedConversationRows = 120
|
||||
const fallbackViewportHeight = 800
|
||||
export const maxRenderedConversationRows = defaultMaxRenderedRows
|
||||
|
||||
export function conversationListOverscan(viewportHeight: number): number {
|
||||
return Math.max(320, Math.round(viewportHeight))
|
||||
}
|
||||
|
||||
export type ConversationListSegment =
|
||||
| { kind: 'spacer'; key: string; height: number }
|
||||
| { kind: 'row'; index: number }
|
||||
|
||||
class ConversationRowHeights {
|
||||
private readonly sizes = new Map<string, number>()
|
||||
private estimateValue = estimatedConversationRowHeight
|
||||
private viewportValue = 0
|
||||
|
||||
get viewport(): number {
|
||||
return this.viewportValue > 0 ? this.viewportValue : fallbackViewportHeight
|
||||
}
|
||||
|
||||
setViewport(height: number): boolean {
|
||||
if (height <= 0 || Math.abs(height - this.viewportValue) < 1) return false
|
||||
this.viewportValue = height
|
||||
return true
|
||||
}
|
||||
|
||||
size(id: string): number {
|
||||
return this.sizes.get(id) ?? this.estimateValue
|
||||
}
|
||||
|
||||
/** Records a height; returns whether layout may have changed. */
|
||||
set(id: string, height: number): boolean {
|
||||
const previous = this.sizes.get(id)
|
||||
if (previous !== undefined) {
|
||||
if (Math.abs(previous - height) < 0.5) return false
|
||||
this.sizes.set(id, height)
|
||||
return true
|
||||
}
|
||||
const count = this.sizes.size
|
||||
this.estimateValue = count === 0 ? height : (this.estimateValue * count + height) / (count + 1)
|
||||
this.sizes.set(id, height)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type Layout = {
|
||||
/** offsets[i] is the top of row i; offsets[count] is the total height. */
|
||||
offsets: number[]
|
||||
start: number
|
||||
end: number
|
||||
viewport: number
|
||||
}
|
||||
|
||||
/** Rows covering the viewport whose top is `scrollTop`, plus `overscan` on both sides. */
|
||||
export function conversationWindowRange(
|
||||
offsets: readonly number[],
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
overscan: number,
|
||||
maxCount = maxRenderedConversationRows,
|
||||
): { start: number; end: number } {
|
||||
const count = offsets.length - 1
|
||||
const total = offsets[count] ?? 0
|
||||
const top = Math.min(Math.max(0, scrollTop), Math.max(0, total - viewportHeight))
|
||||
let start = 0
|
||||
while (start < count && offsets[start + 1]! <= top - overscan) start += 1
|
||||
let end = start
|
||||
while (end < count && offsets[end]! < top + viewportHeight + overscan) end += 1
|
||||
if (end - start > maxCount) {
|
||||
// Keep the rows at the viewport top; trim the overscan evenly.
|
||||
let first = start
|
||||
while (first < end && offsets[first + 1]! <= top) first += 1
|
||||
start = Math.max(start, Math.min(first - Math.floor(maxCount / 4), end - maxCount))
|
||||
end = Math.min(end, start + maxCount)
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
export const conversationListOverscan = listWindowOverscan
|
||||
export const conversationWindowRange = listWindowRange
|
||||
export type ConversationListSegment = ListWindowSegment
|
||||
|
||||
export function useConversationListWindow({
|
||||
ids,
|
||||
@@ -109,202 +43,13 @@ export function useConversationListWindow({
|
||||
keepIds: readonly (string | undefined)[]
|
||||
enabled: boolean
|
||||
}) {
|
||||
const windowed = enabled && ids.length >= conversationListWindowThreshold
|
||||
const [heights] = useState(() => new ConversationRowHeights())
|
||||
const [measureVersion, setMeasureVersion] = useState(0)
|
||||
// scrollTop at the last re-window; the range is derived from it.
|
||||
const [anchorTop, setAnchorTop] = useState(0)
|
||||
const [focusedId, setFocusedId] = useState<string | undefined>(undefined)
|
||||
const [seenScope, setSeenScope] = useState(scope)
|
||||
if (seenScope !== scope) {
|
||||
setSeenScope(scope)
|
||||
setAnchorTop(0)
|
||||
}
|
||||
|
||||
const layoutRef = useRef<Layout | undefined>(undefined)
|
||||
const rowElements = useRef(new Map<string, HTMLElement>())
|
||||
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
|
||||
const observerRef = useRef<ResizeObserver | undefined>(undefined)
|
||||
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
|
||||
const scopeRef = useRef(scope)
|
||||
|
||||
const keep = new Set([...keepIds, focusedId].filter((id): id is string => Boolean(id)))
|
||||
// Kept rows change the mounted set (and spacers), so the layout effect reruns.
|
||||
const keepKey = [...keep].join('\n')
|
||||
let segments: ConversationListSegment[]
|
||||
let layout: Layout | undefined
|
||||
if (!windowed) {
|
||||
segments = ids.map((_, index) => ({ kind: 'row', index }))
|
||||
} else {
|
||||
const offsets = new Array<number>(ids.length + 1)
|
||||
offsets[0] = 0
|
||||
for (let index = 0; index < ids.length; index += 1) {
|
||||
offsets[index + 1] = offsets[index]! + heights.size(ids[index]!)
|
||||
}
|
||||
const viewport = heights.viewport
|
||||
const range = conversationWindowRange(offsets, anchorTop, viewport, conversationListOverscan(viewport))
|
||||
layout = { offsets, start: range.start, end: range.end, viewport }
|
||||
const rendered: number[] = []
|
||||
for (let index = range.start; index < range.end; index += 1) rendered.push(index)
|
||||
if (keep.size) {
|
||||
ids.forEach((id, index) => {
|
||||
if (keep.has(id) && (index < range.start || index >= range.end)) rendered.push(index)
|
||||
})
|
||||
rendered.sort((left, right) => left - right)
|
||||
}
|
||||
// The top spacer is always present so it keeps its identity while the window moves.
|
||||
segments = [{ kind: 'spacer', key: 'top', height: 0 }]
|
||||
let previous = 0
|
||||
for (const index of rendered) {
|
||||
if (index > previous) {
|
||||
const height = offsets[index]! - offsets[previous]!
|
||||
if (previous === 0) segments[0] = { kind: 'spacer', key: 'top', height }
|
||||
else segments.push({ kind: 'spacer', key: `gap:${ids[previous]}`, height })
|
||||
}
|
||||
segments.push({ kind: 'row', index })
|
||||
previous = index + 1
|
||||
}
|
||||
if (previous < ids.length) {
|
||||
segments.push({ kind: 'spacer', key: 'bottom', height: offsets[ids.length]! - offsets[previous]! })
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
layoutRef.current = layout
|
||||
return useListWindow({
|
||||
ids,
|
||||
scope,
|
||||
scrollRef,
|
||||
keepIds,
|
||||
enabled,
|
||||
rowAttribute: 'data-conversation-window-row',
|
||||
resetScrollOnScopeChange: true,
|
||||
})
|
||||
|
||||
/** First mounted row reaching into the viewport, relative to the viewport top. */
|
||||
const captureScrollAnchor = useCallback((): void => {
|
||||
const container = scrollRef.current
|
||||
if (!container || container.scrollTop <= 0) {
|
||||
scrollAnchorRef.current = undefined
|
||||
return
|
||||
}
|
||||
const viewportTop = container.getBoundingClientRect().top
|
||||
for (const element of container.querySelectorAll<HTMLElement>('[data-conversation-window-row]')) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.bottom > viewportTop) {
|
||||
scrollAnchorRef.current = { id: element.dataset.conversationWindowRow ?? '', top: rect.top - viewportTop }
|
||||
return
|
||||
}
|
||||
}
|
||||
scrollAnchorRef.current = undefined
|
||||
}, [scrollRef])
|
||||
|
||||
// Row and viewport measurement.
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollRef.current
|
||||
if (!windowed || !container) return
|
||||
if (heights.setViewport(container.clientHeight)) setMeasureVersion((version) => version + 1)
|
||||
if (typeof ResizeObserver !== 'function') return
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const target = entry.target as HTMLElement
|
||||
if (target === container) {
|
||||
if (heights.setViewport(container.clientHeight)) changed = true
|
||||
continue
|
||||
}
|
||||
const id = target.dataset?.conversationWindowRow
|
||||
if (!id) continue
|
||||
const height = target.getBoundingClientRect().height
|
||||
if (height > 0 && heights.set(id, height)) changed = true
|
||||
}
|
||||
if (changed) {
|
||||
// Spacer sizes may change with the new estimate; keep the visible row in place.
|
||||
captureScrollAnchor()
|
||||
setMeasureVersion((version) => version + 1)
|
||||
}
|
||||
})
|
||||
observer.observe(container)
|
||||
for (const element of rowElements.current.values()) observer.observe(element)
|
||||
observerRef.current = observer
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
observerRef.current = undefined
|
||||
}
|
||||
}, [captureScrollAnchor, heights, scrollRef, windowed])
|
||||
|
||||
// After each commit: restore the visible row's position after a re-layout,
|
||||
// return to the top on a scope change, and re-window when the scroll
|
||||
// position left the mounted rows without a scroll event (content shrank).
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollRef.current
|
||||
if (!container) return
|
||||
if (scopeRef.current !== scope) {
|
||||
scopeRef.current = scope
|
||||
scrollAnchorRef.current = undefined
|
||||
// New search results or another project start at the top; short lists
|
||||
// keep the browser's own clamping, as before windowing.
|
||||
if (windowed && container.scrollTop !== 0) container.scrollTop = 0
|
||||
}
|
||||
const anchor = scrollAnchorRef.current
|
||||
scrollAnchorRef.current = undefined
|
||||
if (windowed && anchor) {
|
||||
const element = rowElements.current.get(anchor.id)
|
||||
if (element) {
|
||||
const delta = element.getBoundingClientRect().top - container.getBoundingClientRect().top - anchor.top
|
||||
if (Math.abs(delta) >= 0.5) container.scrollTop += delta
|
||||
}
|
||||
}
|
||||
const current = layoutRef.current
|
||||
if (!windowed || !current) return
|
||||
const scrollTop = container.scrollTop
|
||||
if (!coversViewport(current, scrollTop) && Math.abs(scrollTop - anchorTop) >= 1) setAnchorTop(scrollTop)
|
||||
}, [anchorTop, ids, keepKey, measureVersion, scope, scrollRef, windowed])
|
||||
|
||||
const onScroll = useCallback((): void => {
|
||||
const container = scrollRef.current
|
||||
const current = layoutRef.current
|
||||
if (!container || !current) return
|
||||
if (heights.setViewport(container.clientHeight)) setMeasureVersion((version) => version + 1)
|
||||
const scrollTop = container.scrollTop
|
||||
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
|
||||
// mounted rows, so a steady scroll remounts rows every screen or so.
|
||||
if (!coversViewport(current, scrollTop)) setAnchorTop(scrollTop)
|
||||
}, [heights, scrollRef])
|
||||
|
||||
const rowRef = useCallback((id: string): ((element: HTMLElement | null) => void) => {
|
||||
let callback = rowRefCallbacks.current.get(id)
|
||||
if (!callback) {
|
||||
callback = (element) => {
|
||||
const previous = rowElements.current.get(id)
|
||||
if (previous && previous !== element) {
|
||||
observerRef.current?.unobserve(previous)
|
||||
rowElements.current.delete(id)
|
||||
}
|
||||
if (element && previous !== element) {
|
||||
rowElements.current.set(id, element)
|
||||
observerRef.current?.observe(element)
|
||||
}
|
||||
if (!element) rowRefCallbacks.current.delete(id)
|
||||
}
|
||||
rowRefCallbacks.current.set(id, callback)
|
||||
}
|
||||
return callback
|
||||
}, [])
|
||||
|
||||
const onFocus = useCallback((event: React.FocusEvent<HTMLElement>): void => {
|
||||
const row = event.target instanceof Element
|
||||
? event.target.closest<HTMLElement>('[data-conversation-window-row]')
|
||||
: null
|
||||
setFocusedId(row?.dataset.conversationWindowRow)
|
||||
}, [])
|
||||
const onBlur = useCallback((event: React.FocusEvent<HTMLElement>): void => {
|
||||
const next = event.relatedTarget
|
||||
// Focus leaving the window (null) keeps the row; moving elsewhere releases it.
|
||||
if (next instanceof Node && !event.currentTarget.contains(next)) setFocusedId(undefined)
|
||||
}, [])
|
||||
|
||||
return { windowed, segments, rowRef, onScroll, onFocus, onBlur }
|
||||
}
|
||||
|
||||
function coversViewport(layout: Layout, scrollTop: number): boolean {
|
||||
const count = layout.offsets.length - 1
|
||||
const margin = Math.round(layout.viewport / 4)
|
||||
const renderedTop = layout.offsets[layout.start] ?? 0
|
||||
const renderedBottom = layout.offsets[layout.end] ?? 0
|
||||
const topCovered = layout.start === 0 || renderedTop <= scrollTop - margin
|
||||
const bottomCovered = layout.end >= count || renderedBottom >= scrollTop + layout.viewport + margin
|
||||
return topCovered && bottomCovered
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { defaultListWindowThreshold, useListWindow, type ListWindowOptions } from './use-list-window'
|
||||
|
||||
/** `gap` is the grid's column gap; windowed rows reuse it as their row spacing. */
|
||||
export type GridTrackMetrics = { columns: number; gap: number }
|
||||
|
||||
/**
|
||||
* Resolved column count and row gap of a CSS grid; follows its width.
|
||||
* Changing `layoutKey` re-reads them (the grid's class, and so its tracks, changed).
|
||||
*/
|
||||
export function useGridColumns(
|
||||
gridRef: React.RefObject<HTMLElement | null>,
|
||||
enabled: boolean,
|
||||
layoutKey = '',
|
||||
): GridTrackMetrics {
|
||||
const [metrics, setMetrics] = useState<GridTrackMetrics>({ columns: 1, gap: 0 })
|
||||
useLayoutEffect(() => {
|
||||
const grid = gridRef.current
|
||||
if (!enabled || !grid) return
|
||||
const update = (): void => {
|
||||
// Hidden grids report no tracks; keep the last layout until shown again.
|
||||
if (grid.closest('[hidden]')) return
|
||||
const next = readGridMetrics(grid)
|
||||
setMetrics((current) =>
|
||||
current.columns === next.columns && Math.abs(current.gap - next.gap) < 0.5 ? current : next)
|
||||
}
|
||||
update()
|
||||
if (typeof ResizeObserver !== 'function') {
|
||||
window.addEventListener('resize', update)
|
||||
return () => window.removeEventListener('resize', update)
|
||||
}
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(grid)
|
||||
return () => observer.disconnect()
|
||||
}, [enabled, gridRef, layoutKey])
|
||||
return metrics
|
||||
}
|
||||
|
||||
export function readGridMetrics(grid: HTMLElement): GridTrackMetrics {
|
||||
const style = getComputedStyle(grid)
|
||||
// Computed `grid-template-columns` lists one resolved length per track.
|
||||
const tracks = (style.gridTemplateColumns ?? '').trim().split(/\s+/).filter((track) => /^[\d.]+px$/.test(track))
|
||||
const gap = Number.parseFloat(style.columnGap)
|
||||
return { columns: Math.max(1, tracks.length), gap: Number.isFinite(gap) ? gap : 0 }
|
||||
}
|
||||
|
||||
export type GridWindowSegment =
|
||||
| { kind: 'spacer'; key: string; height: number }
|
||||
| { kind: 'row'; key: string; start: number; end: number }
|
||||
|
||||
/**
|
||||
* Windowing for a CSS grid of cards that scrolls itself: items are grouped in
|
||||
* rows of the grid's current column count and the rows are windowed. Kept
|
||||
* items (selection, open menu, focus) keep their whole row mounted.
|
||||
*
|
||||
* While windowed, each row should be a full-width subgrid item
|
||||
* (`grid-column: 1 / -1; grid-template-columns: subgrid`) and spacers full-width
|
||||
* blocks of the given height; the grid's own `row-gap` must then be 0, with
|
||||
* the returned `gap` carried inside each row as bottom padding, so the
|
||||
* measured row height includes it.
|
||||
*/
|
||||
export function useGridListWindow({
|
||||
itemIds,
|
||||
gridRef,
|
||||
keepIds,
|
||||
layoutKey,
|
||||
...options
|
||||
}: Omit<ListWindowOptions, 'ids' | 'scrollRef' | 'listRef' | 'keepIds' | 'layoutKey'> & {
|
||||
itemIds: readonly string[]
|
||||
gridRef: React.RefObject<HTMLElement | null>
|
||||
keepIds: readonly (string | undefined)[]
|
||||
/** Card layout (view mode); rows are re-measured when it or the column count changes. */
|
||||
layoutKey: string
|
||||
}) {
|
||||
// The threshold counts items, not grid rows.
|
||||
const enabled = options.enabled && itemIds.length >= (options.threshold ?? defaultListWindowThreshold)
|
||||
const { columns, gap } = useGridColumns(gridRef, enabled, layoutKey)
|
||||
const itemKey = itemIds.join('\n')
|
||||
const rowIds = useMemo(() => {
|
||||
const items = itemKey ? itemKey.split('\n') : []
|
||||
const rows: string[] = []
|
||||
for (let index = 0; index < items.length; index += columns) rows.push(`row:${items[index]}`)
|
||||
return rows
|
||||
}, [columns, itemKey])
|
||||
const rowIndexById = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
itemIds.forEach((id, index) => map.set(id, Math.floor(index / columns)))
|
||||
return map
|
||||
}, [columns, itemIds])
|
||||
const keepRows = keepIds.map((id) => {
|
||||
const row = id ? rowIndexById.get(id) : undefined
|
||||
return row === undefined ? undefined : rowIds[row]
|
||||
})
|
||||
const listWindow = useListWindow({
|
||||
...options,
|
||||
enabled,
|
||||
threshold: 1,
|
||||
ids: rowIds,
|
||||
scrollRef: gridRef,
|
||||
keepIds: keepRows,
|
||||
layoutKey: `${layoutKey}:${columns}`,
|
||||
})
|
||||
const segments: GridWindowSegment[] = listWindow.segments.map((segment) =>
|
||||
segment.kind === 'spacer'
|
||||
? segment
|
||||
: {
|
||||
kind: 'row',
|
||||
key: rowIds[segment.index]!,
|
||||
start: segment.index * columns,
|
||||
end: Math.min(itemIds.length, (segment.index + 1) * columns),
|
||||
})
|
||||
return { ...listWindow, segments, columns, gap }
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Windowing for long renderer lists (PERF-14, P5).
|
||||
*
|
||||
* Long lists mount only the rows near the viewport; spacers stand in for the
|
||||
* rest, sized from measured row heights (or the mean measured height for rows
|
||||
* never rendered). The list is top-anchored like any normal list. Rows that
|
||||
* hold user state (focus, open menu, rename, the selection) stay mounted at
|
||||
* their real position even when scrolled away, so DOM and Tab order follow
|
||||
* the list and `scrollIntoView` on such a row lands where it belongs.
|
||||
*
|
||||
* The scrolling element is either the list itself (`scrollRef` only) or an
|
||||
* ancestor (`listRef` plus `scrollParent`), in which case the list may sit
|
||||
* anywhere inside it, below other content. Without any scroll parent the
|
||||
* window follows the document viewport.
|
||||
*/
|
||||
|
||||
/** Lists shorter than this render every row (no windowing). */
|
||||
export const defaultListWindowThreshold = 60
|
||||
/** Height assumed for rows before any row has been measured. */
|
||||
export const defaultEstimatedRowHeight = 40
|
||||
/** Upper bound for rows mounted from the range (kept rows come on top). */
|
||||
export const defaultMaxRenderedRows = 120
|
||||
/** Row attribute carrying the row id; spacers carry `data-list-window-spacer`. */
|
||||
export const defaultListWindowRowAttribute = 'data-list-window-row'
|
||||
const fallbackViewportHeight = 800
|
||||
|
||||
export function listWindowOverscan(viewportHeight: number): number {
|
||||
return Math.max(320, Math.round(viewportHeight))
|
||||
}
|
||||
|
||||
export type ListWindowSegment =
|
||||
| { kind: 'spacer'; key: string; height: number }
|
||||
| { kind: 'row'; index: number }
|
||||
|
||||
class RowHeights {
|
||||
private readonly sizes = new Map<string, number>()
|
||||
private estimateValue: number
|
||||
private viewportValue = 0
|
||||
|
||||
constructor(estimate: number) {
|
||||
this.estimateValue = estimate
|
||||
}
|
||||
|
||||
/** Forgets every measurement (the row layout changed, e.g. a new column count). */
|
||||
reset(estimate: number): void {
|
||||
this.sizes.clear()
|
||||
this.estimateValue = estimate
|
||||
}
|
||||
|
||||
get viewport(): number {
|
||||
return this.viewportValue > 0 ? this.viewportValue : fallbackViewportHeight
|
||||
}
|
||||
|
||||
setViewport(height: number): boolean {
|
||||
if (height <= 0 || Math.abs(height - this.viewportValue) < 1) return false
|
||||
this.viewportValue = height
|
||||
return true
|
||||
}
|
||||
|
||||
size(id: string): number {
|
||||
return this.sizes.get(id) ?? this.estimateValue
|
||||
}
|
||||
|
||||
/** Records a height; returns whether layout may have changed. */
|
||||
set(id: string, height: number): boolean {
|
||||
const previous = this.sizes.get(id)
|
||||
if (previous !== undefined) {
|
||||
if (Math.abs(previous - height) < 0.5) return false
|
||||
this.sizes.set(id, height)
|
||||
return true
|
||||
}
|
||||
const count = this.sizes.size
|
||||
this.estimateValue = count === 0 ? height : (this.estimateValue * count + height) / (count + 1)
|
||||
this.sizes.set(id, height)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type Layout = {
|
||||
/** offsets[i] is the top of row i; offsets[count] is the total height. */
|
||||
offsets: number[]
|
||||
start: number
|
||||
end: number
|
||||
viewport: number
|
||||
}
|
||||
|
||||
/** Rows covering the viewport whose top is `scrollTop`, plus `overscan` on both sides. */
|
||||
export function listWindowRange(
|
||||
offsets: readonly number[],
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
overscan: number,
|
||||
maxCount = defaultMaxRenderedRows,
|
||||
): { start: number; end: number } {
|
||||
const count = offsets.length - 1
|
||||
const total = offsets[count] ?? 0
|
||||
const top = Math.min(Math.max(0, scrollTop), Math.max(0, total - viewportHeight))
|
||||
let start = 0
|
||||
while (start < count && offsets[start + 1]! <= top - overscan) start += 1
|
||||
let end = start
|
||||
while (end < count && offsets[end]! < top + viewportHeight + overscan) end += 1
|
||||
if (end - start > maxCount) {
|
||||
// Keep the rows at the viewport top; trim the overscan evenly.
|
||||
let first = start
|
||||
while (first < end && offsets[first + 1]! <= top) first += 1
|
||||
start = Math.max(start, Math.min(first - Math.floor(maxCount / 4), end - maxCount))
|
||||
end = Math.min(end, start + maxCount)
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export type ListWindowOptions = {
|
||||
/** Row ids in display order. */
|
||||
ids: readonly string[]
|
||||
/** Changing the scope (project, search, folder) re-windows from the top. */
|
||||
scope: string
|
||||
/** The list element when it scrolls itself. */
|
||||
scrollRef?: React.RefObject<HTMLElement | null>
|
||||
/** The element containing the rows when an ancestor scrolls. */
|
||||
listRef?: React.RefObject<HTMLElement | null>
|
||||
/** Finds the scrolling ancestor of `listRef`; null follows the document viewport. */
|
||||
scrollParent?: (list: HTMLElement) => HTMLElement | null
|
||||
/** Ids that must stay mounted (selected, menu open, renaming...). */
|
||||
keepIds: readonly (string | undefined)[]
|
||||
enabled: boolean
|
||||
rowAttribute?: string
|
||||
threshold?: number
|
||||
estimatedRowHeight?: number
|
||||
maxRenderedRows?: number
|
||||
/** Scroll back to the top on a scope change (sidebar search results). */
|
||||
resetScrollOnScopeChange?: boolean
|
||||
/** Changing it drops measured heights (rows were re-laid out). */
|
||||
layoutKey?: string
|
||||
/**
|
||||
* False while the list is hidden (`hidden`, a collapsed view): its geometry
|
||||
* is meaningless then. Turning it back on re-windows at the current position.
|
||||
*/
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
type Geometry = {
|
||||
list: HTMLElement
|
||||
/** Scrolling element; undefined follows the document viewport. */
|
||||
container: HTMLElement | undefined
|
||||
}
|
||||
|
||||
export function useListWindow({
|
||||
ids,
|
||||
scope,
|
||||
scrollRef,
|
||||
listRef,
|
||||
scrollParent,
|
||||
keepIds,
|
||||
enabled,
|
||||
rowAttribute = defaultListWindowRowAttribute,
|
||||
threshold = defaultListWindowThreshold,
|
||||
estimatedRowHeight = defaultEstimatedRowHeight,
|
||||
maxRenderedRows = defaultMaxRenderedRows,
|
||||
resetScrollOnScopeChange = false,
|
||||
active = true,
|
||||
layoutKey = '',
|
||||
}: ListWindowOptions) {
|
||||
const windowed = enabled && ids.length >= threshold
|
||||
const [heights] = useState(() => new RowHeights(estimatedRowHeight))
|
||||
const [measureVersion, setMeasureVersion] = useState(0)
|
||||
const [seenLayoutKey, setSeenLayoutKey] = useState(layoutKey)
|
||||
if (seenLayoutKey !== layoutKey) {
|
||||
setSeenLayoutKey(layoutKey)
|
||||
heights.reset(estimatedRowHeight)
|
||||
}
|
||||
// List-relative scroll position at the last re-window; the range is derived from it.
|
||||
const [anchorTop, setAnchorTop] = useState(0)
|
||||
const [focusedId, setFocusedId] = useState<string | undefined>(undefined)
|
||||
const [seenScope, setSeenScope] = useState(scope)
|
||||
if (seenScope !== scope) {
|
||||
setSeenScope(scope)
|
||||
setAnchorTop(0)
|
||||
}
|
||||
|
||||
const layoutRef = useRef<Layout | undefined>(undefined)
|
||||
const rowElements = useRef(new Map<string, HTMLElement>())
|
||||
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
|
||||
const observerRef = useRef<ResizeObserver | undefined>(undefined)
|
||||
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
|
||||
const scopeRef = useRef(scope)
|
||||
const scrollParentRef = useRef(scrollParent)
|
||||
useLayoutEffect(() => {
|
||||
scrollParentRef.current = scrollParent
|
||||
})
|
||||
|
||||
const keep = new Set([...keepIds, focusedId].filter((id): id is string => Boolean(id)))
|
||||
// Kept rows change the mounted set (and spacers), so the layout effect reruns.
|
||||
const keepKey = [...keep].join('\n')
|
||||
let segments: ListWindowSegment[]
|
||||
let layout: Layout | undefined
|
||||
if (!windowed) {
|
||||
segments = ids.map((_, index) => ({ kind: 'row', index }))
|
||||
} else {
|
||||
const offsets = new Array<number>(ids.length + 1)
|
||||
offsets[0] = 0
|
||||
for (let index = 0; index < ids.length; index += 1) {
|
||||
offsets[index + 1] = offsets[index]! + heights.size(ids[index]!)
|
||||
}
|
||||
const viewport = heights.viewport
|
||||
const range = listWindowRange(offsets, anchorTop, viewport, listWindowOverscan(viewport), maxRenderedRows)
|
||||
layout = { offsets, start: range.start, end: range.end, viewport }
|
||||
const rendered: number[] = []
|
||||
for (let index = range.start; index < range.end; index += 1) rendered.push(index)
|
||||
if (keep.size) {
|
||||
ids.forEach((id, index) => {
|
||||
if (keep.has(id) && (index < range.start || index >= range.end)) rendered.push(index)
|
||||
})
|
||||
rendered.sort((left, right) => left - right)
|
||||
}
|
||||
// The top spacer is always present so it keeps its identity while the window moves.
|
||||
segments = [{ kind: 'spacer', key: 'top', height: 0 }]
|
||||
let previous = 0
|
||||
for (const index of rendered) {
|
||||
if (index > previous) {
|
||||
const height = offsets[index]! - offsets[previous]!
|
||||
if (previous === 0) segments[0] = { kind: 'spacer', key: 'top', height }
|
||||
else segments.push({ kind: 'spacer', key: `gap:${ids[previous]}`, height })
|
||||
}
|
||||
segments.push({ kind: 'row', index })
|
||||
previous = index + 1
|
||||
}
|
||||
if (previous < ids.length) {
|
||||
segments.push({ kind: 'spacer', key: 'bottom', height: offsets[ids.length]! - offsets[previous]! })
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
layoutRef.current = layout
|
||||
})
|
||||
|
||||
const geometry = useCallback((): Geometry | undefined => {
|
||||
const list = listRef?.current ?? scrollRef?.current
|
||||
if (!list) return undefined
|
||||
const container = scrollRef?.current ?? scrollParentRef.current?.(list) ?? undefined
|
||||
return { list, container }
|
||||
}, [listRef, scrollRef])
|
||||
|
||||
const rowSelector = `[${rowAttribute}]`
|
||||
|
||||
/** Viewport top in client coordinates. */
|
||||
const viewportTop = (container: HTMLElement | undefined): number =>
|
||||
container ? container.getBoundingClientRect().top : 0
|
||||
|
||||
/** Scroll position relative to the list top, and the viewport height. */
|
||||
const position = useCallback(({ list, container }: Geometry): { top: number; viewport: number } => {
|
||||
if (container === list) return { top: list.scrollTop, viewport: list.clientHeight }
|
||||
const top = viewportTop(container) - list.getBoundingClientRect().top
|
||||
return { top, viewport: container ? container.clientHeight : window.innerHeight }
|
||||
}, [])
|
||||
|
||||
/** First mounted row reaching into the viewport, relative to the viewport top. */
|
||||
const captureScrollAnchor = useCallback((): void => {
|
||||
const current = geometry()
|
||||
if (!current || position(current).top <= 0) {
|
||||
scrollAnchorRef.current = undefined
|
||||
return
|
||||
}
|
||||
const top = viewportTop(current.container)
|
||||
for (const element of current.list.querySelectorAll<HTMLElement>(rowSelector)) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.bottom > top) {
|
||||
scrollAnchorRef.current = { id: element.getAttribute(rowAttribute) ?? '', top: rect.top - top }
|
||||
return
|
||||
}
|
||||
}
|
||||
scrollAnchorRef.current = undefined
|
||||
}, [geometry, position, rowAttribute, rowSelector])
|
||||
|
||||
const activeRef = useRef(active)
|
||||
useLayoutEffect(() => {
|
||||
activeRef.current = active
|
||||
})
|
||||
|
||||
/** Re-windows when the viewport (plus a margin) left the mounted rows. */
|
||||
const follow = useCallback((): void => {
|
||||
const current = geometry()
|
||||
const layoutNow = layoutRef.current
|
||||
if (!current || !layoutNow || !activeRef.current || isHidden(current.list)) return
|
||||
const { top, viewport } = position(current)
|
||||
if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1)
|
||||
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
|
||||
// mounted rows, so a steady scroll remounts rows every screen or so.
|
||||
if (!coversViewport(layoutNow, top)) setAnchorTop(top)
|
||||
}, [geometry, heights, position])
|
||||
|
||||
// Row and viewport measurement.
|
||||
useLayoutEffect(() => {
|
||||
const current = geometry()
|
||||
if (!windowed || !current) return
|
||||
const { container, list } = current
|
||||
const onWindowResize = (): void => follow()
|
||||
if (!container) window.addEventListener('resize', onWindowResize)
|
||||
if (typeof ResizeObserver !== 'function') {
|
||||
return () => window.removeEventListener('resize', onWindowResize)
|
||||
}
|
||||
// The observer reports every observed element once on `observe`, which
|
||||
// delivers the first viewport height without a synchronous re-render here.
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const target = entry.target as HTMLElement
|
||||
if (target === (container ?? list)) {
|
||||
if (heights.setViewport(container ? container.clientHeight : window.innerHeight)) changed = true
|
||||
if (target !== list) continue
|
||||
}
|
||||
const id = target.getAttribute?.(rowAttribute)
|
||||
if (!id) continue
|
||||
const height = target.getBoundingClientRect().height
|
||||
if (height > 0 && heights.set(id, height)) changed = true
|
||||
}
|
||||
if (changed) {
|
||||
// Spacer sizes may change with the new estimate; keep the visible row in place.
|
||||
captureScrollAnchor()
|
||||
setMeasureVersion((version) => version + 1)
|
||||
}
|
||||
})
|
||||
observer.observe(container ?? list)
|
||||
for (const element of rowElements.current.values()) observer.observe(element)
|
||||
observerRef.current = observer
|
||||
return () => {
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
observer.disconnect()
|
||||
observerRef.current = undefined
|
||||
}
|
||||
}, [captureScrollAnchor, follow, geometry, heights, rowAttribute, windowed])
|
||||
|
||||
// An ancestor (or the document) scrolls: listen to it here.
|
||||
useLayoutEffect(() => {
|
||||
const current = geometry()
|
||||
if (!windowed || !current || current.container === current.list) return
|
||||
const target: HTMLElement | Window = current.container ?? window
|
||||
const onScroll = (): void => follow()
|
||||
target.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => target.removeEventListener('scroll', onScroll)
|
||||
}, [follow, geometry, windowed])
|
||||
|
||||
// After each commit: restore the visible row's position after a re-layout,
|
||||
// return to the top on a scope change, and re-window when the scroll
|
||||
// position left the mounted rows without a scroll event (content shrank).
|
||||
useLayoutEffect(() => {
|
||||
const current = geometry()
|
||||
if (!current) return
|
||||
const { container } = current
|
||||
if (scopeRef.current !== scope) {
|
||||
scopeRef.current = scope
|
||||
scrollAnchorRef.current = undefined
|
||||
// New search results start at the top; short lists keep the browser's
|
||||
// own clamping, as before windowing.
|
||||
if (resetScrollOnScopeChange && windowed && container && container.scrollTop !== 0) container.scrollTop = 0
|
||||
}
|
||||
const anchor = scrollAnchorRef.current
|
||||
scrollAnchorRef.current = undefined
|
||||
if (windowed && anchor) {
|
||||
const element = rowElements.current.get(anchor.id)
|
||||
if (element) {
|
||||
const delta = element.getBoundingClientRect().top - viewportTop(container) - anchor.top
|
||||
if (Math.abs(delta) >= 0.5) {
|
||||
if (container) container.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
const layoutNow = layoutRef.current
|
||||
if (!windowed || !layoutNow || !active || isHidden(current.list)) return
|
||||
const { top } = position(current)
|
||||
if (!coversViewport(layoutNow, top) && Math.abs(top - anchorTop) >= 1) setAnchorTop(top)
|
||||
}, [active, anchorTop, geometry, ids, keepKey, measureVersion, position, resetScrollOnScopeChange, scope, windowed])
|
||||
|
||||
/** For lists that scroll themselves: wire to the list's onScroll. */
|
||||
const onScroll = useCallback((): void => follow(), [follow])
|
||||
|
||||
const rowRef = useCallback((id: string): ((element: HTMLElement | null) => void) => {
|
||||
let callback = rowRefCallbacks.current.get(id)
|
||||
if (!callback) {
|
||||
callback = (element) => {
|
||||
const previous = rowElements.current.get(id)
|
||||
if (previous && previous !== element) {
|
||||
observerRef.current?.unobserve(previous)
|
||||
rowElements.current.delete(id)
|
||||
}
|
||||
if (element && previous !== element) {
|
||||
rowElements.current.set(id, element)
|
||||
observerRef.current?.observe(element)
|
||||
}
|
||||
if (!element) rowRefCallbacks.current.delete(id)
|
||||
}
|
||||
rowRefCallbacks.current.set(id, callback)
|
||||
}
|
||||
return callback
|
||||
}, [])
|
||||
|
||||
// The row that last held focus inside the list; a portaled menu opened from
|
||||
// it (React focus events bubble through portals) keeps that row mounted.
|
||||
const lastFocusedRef = useRef<string | undefined>(undefined)
|
||||
const onFocus = useCallback((event: React.FocusEvent<HTMLElement>): void => {
|
||||
const target = event.target
|
||||
if (target instanceof Node && !event.currentTarget.contains(target)) {
|
||||
// Focus inside a portal rendered by a row: keep its row.
|
||||
setFocusedId(lastFocusedRef.current)
|
||||
return
|
||||
}
|
||||
const row = target instanceof Element ? target.closest<HTMLElement>(rowSelector) : null
|
||||
const id = row?.getAttribute(rowAttribute) ?? undefined
|
||||
lastFocusedRef.current = id
|
||||
setFocusedId(id)
|
||||
}, [rowAttribute, rowSelector])
|
||||
const onBlur = useCallback((event: React.FocusEvent<HTMLElement>): void => {
|
||||
const next = event.relatedTarget
|
||||
// Focus leaving the window (null) keeps the row; moving elsewhere releases
|
||||
// it (a portal of this list gets it back from its own focus event).
|
||||
if (next instanceof Node && !event.currentTarget.contains(next)) setFocusedId(undefined)
|
||||
}, [])
|
||||
|
||||
return { windowed, segments, rowRef, onScroll, onFocus, onBlur }
|
||||
}
|
||||
|
||||
/** A list inside a `hidden` subtree has no geometry; leave its window alone. */
|
||||
function isHidden(list: HTMLElement): boolean {
|
||||
return list.closest('[hidden]') !== null
|
||||
}
|
||||
|
||||
function coversViewport(layout: Layout, scrollTop: number): boolean {
|
||||
const count = layout.offsets.length - 1
|
||||
const margin = Math.round(layout.viewport / 4)
|
||||
const renderedTop = layout.offsets[layout.start] ?? 0
|
||||
const renderedBottom = layout.offsets[layout.end] ?? 0
|
||||
const topCovered = layout.start === 0 || renderedTop <= scrollTop - margin
|
||||
const bottomCovered = layout.end >= count || renderedBottom >= scrollTop + layout.viewport + margin
|
||||
return topCovered && bottomCovered
|
||||
}
|
||||
Reference in New Issue
Block a user