mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[aidan] feat/schedule-list: window long list via measured-height virtualizer
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
@@ -12,6 +12,7 @@ import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel, stepsSignature } from './scheduleUtils';
|
||||
import { useWindowedList } from '@/shared/hooks/useWindowedList';
|
||||
|
||||
interface Props {
|
||||
view: 'Week' | 'Month' | 'List';
|
||||
@@ -25,14 +26,24 @@ interface Props {
|
||||
// starting hour. The scroll container caps the visible window.
|
||||
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
// List view pages in this many occurrences at a time as you scroll.
|
||||
const LIST_PAGE = 60;
|
||||
// At/above this many list rows (day headers + event rows), window the list so
|
||||
// only near-viewport rows stay mounted. Below it, render whole; spacers aren't
|
||||
// worth the churn on a short list.
|
||||
const LIST_WINDOW_MIN_ROWS = 60;
|
||||
|
||||
interface CalendarEvent {
|
||||
workflow_id: string;
|
||||
fire_at: string;
|
||||
}
|
||||
|
||||
// One flattened list row. Windowing unmounts at this granularity, so a dense
|
||||
// single day no longer mounts all ~96 of its rows just for being near the
|
||||
// viewport: only the rows actually in view (plus buffer) stay in the DOM.
|
||||
type ListRow =
|
||||
| { kind: 'header'; id: string; date: Date; isToday: boolean }
|
||||
| { kind: 'event'; id: string; ev: { workflow: Workflow; date: Date } }
|
||||
| { kind: 'empty'; id: string };
|
||||
|
||||
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -109,18 +120,32 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
|
||||
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||
const [calendarFetchKey, setCalendarFetchKey] = useState('');
|
||||
// Key off only the fields that change which occurrences exist. Deliberately
|
||||
// NOT updated_at: the scheduler bumps it every tick (recomputing next_run_at)
|
||||
// and pushes a workflow:updated over the socket, which would churn this key
|
||||
// and blank the calendar (the eventsByDay gate) until the next fetch lands.
|
||||
const workflowScheduleKey = workflows
|
||||
.map((w) => `${w.id}:${w.updated_at}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
||||
.map((w) => `${w.id}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
const fromIso = rangeStart.toISOString();
|
||||
const toIso = rangeEndExclusive.toISOString();
|
||||
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
|
||||
// The visible window alone decides whether shown events are even plausible.
|
||||
// Gating on this (not the full request key) means a schedule edit refetches
|
||||
// without blanking the calendar first: we keep the current events until the
|
||||
// fresh ones land. Only a view/date change, where old events are for the
|
||||
// wrong window, clears them.
|
||||
const calendarWindowKey = `${view}:${fromIso}:${toIso}`;
|
||||
|
||||
useEffect(() => {
|
||||
// No AbortController: the global fetch interceptor (shared/config) dedupes
|
||||
// GETs by URL onto ONE underlying request, so aborting on cleanup (which
|
||||
// fires when this effect re-runs as workflows hydrate) rejects the shared
|
||||
// request and the re-fired fetch with it, leaving the calendar empty on
|
||||
// first load. The `cancelled` guard already stops stale state writes.
|
||||
let cancelled = false;
|
||||
const ctrl = new AbortController();
|
||||
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, { signal: ctrl.signal })
|
||||
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
|
||||
return res.json();
|
||||
@@ -128,22 +153,20 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents((data.events || []) as CalendarEvent[]);
|
||||
setCalendarFetchKey(calendarRequestKey);
|
||||
setCalendarFetchKey(calendarWindowKey);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents([]);
|
||||
setCalendarFetchKey(calendarRequestKey);
|
||||
setCalendarFetchKey(calendarWindowKey);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctrl.abort();
|
||||
};
|
||||
}, [fromIso, toIso, calendarRequestKey]);
|
||||
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
|
||||
if (calendarFetchKey !== calendarRequestKey) {
|
||||
if (calendarFetchKey !== calendarWindowKey) {
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}
|
||||
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
|
||||
@@ -161,25 +184,51 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}, [calendarEvents, calendarFetchKey, calendarRequestKey, workflows, rangeStart, rangeEndExclusive]);
|
||||
}, [calendarEvents, calendarFetchKey, calendarWindowKey, workflows, rangeStart, rangeEndExclusive]);
|
||||
|
||||
// List view can fan out to ~1300 rows for a dense schedule (every 15 min over
|
||||
// 14 days), so render a page at a time and grow as a bottom sentinel scrolls
|
||||
// into view. root:null intersects against the viewport through whichever
|
||||
// ancestor actually scrolls, so this works in both the popover and the hub.
|
||||
const [listVisible, setListVisible] = useState(LIST_PAGE);
|
||||
const listSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => { setListVisible(LIST_PAGE); }, [dayKey, view, eventsByDay.key]);
|
||||
useEffect(() => {
|
||||
if (view !== 'List') return;
|
||||
const el = listSentinelRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) setListVisible((n) => n + LIST_PAGE);
|
||||
}, { rootMargin: '300px' });
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [view, listVisible, eventsByDay.key, dayKey]);
|
||||
// 14 days). Flatten days into rows and window at the row level so off-screen
|
||||
// rows unmount instead of weighing the whole app down. Computed up here (not
|
||||
// in the List branch) so the windowing hook runs before the Week/Month early
|
||||
// returns.
|
||||
const upcoming = useMemo(() => {
|
||||
const out: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const day = addDays(today, i);
|
||||
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
|
||||
const arr = eventsByDay.map.get(key) || [];
|
||||
const isToday = sameDay(day, today);
|
||||
if (arr.length || isToday) out.push({ date: day, events: arr, isToday });
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventsByDay, dayKey]);
|
||||
const rows = useMemo<ListRow[]>(() => {
|
||||
const out: ListRow[] = [];
|
||||
for (const day of upcoming) {
|
||||
const iso = day.date.toISOString();
|
||||
out.push({ kind: 'header', id: `h:${iso}`, date: day.date, isToday: day.isToday });
|
||||
if (day.events.length === 0) {
|
||||
out.push({ kind: 'empty', id: `x:${iso}` });
|
||||
} else {
|
||||
for (const ev of day.events) {
|
||||
out.push({ kind: 'event', id: `${iso}#${ev.workflow.id}#${ev.date.getTime()}`, ev });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [upcoming]);
|
||||
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
|
||||
const estimateRowHeight = useCallback((index: number) => {
|
||||
const r = rows[index];
|
||||
if (!r) return 41;
|
||||
return r.kind === 'header' ? 52 : r.kind === 'empty' ? 36 : 41;
|
||||
}, [rows]);
|
||||
const windowing = useWindowedList({
|
||||
ids: rowIds,
|
||||
estimateHeight: estimateRowHeight,
|
||||
enabled: view === 'List' && rows.length >= LIST_WINDOW_MIN_ROWS,
|
||||
});
|
||||
|
||||
const SLOT_H = compact ? 32 : 44;
|
||||
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
|
||||
@@ -346,81 +395,81 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
);
|
||||
}
|
||||
|
||||
// Apple-Calendar-style list: big day number + weekday on the left, a
|
||||
// vertical colored bar separating it from events on the right. Today
|
||||
// renders even with no events (shows a "No events today" placeholder)
|
||||
// so the list doesn't feel empty for new users.
|
||||
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const day = addDays(today, i);
|
||||
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
|
||||
const arr = eventsByDay.map.get(key) || [];
|
||||
const isToday = sameDay(day, today);
|
||||
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
|
||||
}
|
||||
// Apple-Calendar-style list: each day is a stacked group with the date as a
|
||||
// header and its events listed underneath, so a busy day stays readable top
|
||||
// to bottom instead of crammed beside a date column. Today renders even with
|
||||
// no events (shows a "No events today" placeholder)
|
||||
// so the list doesn't feel empty for new users. Off-screen day groups
|
||||
// unmount (useWindowedList) and leave a measured-height spacer behind, so a
|
||||
// dense schedule stays light no matter how far down you scroll.
|
||||
const accent = c.accent.primary;
|
||||
// Spend the page budget across day groups in order, truncating the group it
|
||||
// runs out on; the leftover events appear when the sentinel grows the budget.
|
||||
const totalEvents = upcoming.reduce((n, u) => n + u.events.length, 0);
|
||||
let budget = listVisible;
|
||||
const windowedDays: typeof upcoming = [];
|
||||
for (const day of upcoming) {
|
||||
windowedDays.push({ ...day, events: day.events.slice(0, Math.max(0, budget)) });
|
||||
budget -= day.events.length;
|
||||
if (budget <= 0) break;
|
||||
}
|
||||
const hasMore = listVisible < totalEvents;
|
||||
const visibleRows = rows.slice(windowing.start, windowing.end);
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
|
||||
{upcoming.length === 0 && (
|
||||
<Box
|
||||
ref={windowing.setScrollEl}
|
||||
onScroll={windowing.onScroll}
|
||||
sx={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', overflow: 'auto', overflowAnchor: 'auto', bgcolor: c.bg.surface }}>
|
||||
{rows.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled</Typography>
|
||||
)}
|
||||
{windowedDays.map(({ date, events, isToday }, rowIdx) => (
|
||||
<Box
|
||||
key={date.toISOString()}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
|
||||
minHeight: 64,
|
||||
}}>
|
||||
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
|
||||
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
|
||||
{date.getDate()}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
|
||||
{date.toLocaleString('en', { month: 'short' })}
|
||||
{windowing.topSpacer > 0 && (
|
||||
<Box aria-hidden sx={{ height: windowing.topSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
|
||||
)}
|
||||
{visibleRows.map((row, i) => {
|
||||
const rowIdx = windowing.start + i;
|
||||
if (row.kind === 'header') {
|
||||
return (
|
||||
<Box
|
||||
key={row.id}
|
||||
data-wl-id={row.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'baseline', gap: 0.75,
|
||||
px: 2, pt: rowIdx === 0 ? 1.5 : 2, pb: 0.5,
|
||||
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.15rem', fontWeight: 700, color: row.isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
|
||||
{row.date.getDate()}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: row.isToday ? accent : c.text.secondary, lineHeight: 1 }}>
|
||||
{WEEKDAY_FULL[row.date.getDay()]}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1 }}>
|
||||
{row.date.toLocaleString('en', { month: 'short' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (row.kind === 'empty') {
|
||||
return (
|
||||
<Box key={row.id} data-wl-id={row.id} sx={{ px: 2, pb: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const e = row.ev;
|
||||
return (
|
||||
<Box
|
||||
key={row.id}
|
||||
data-wl-id={row.id}
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||
px: 2, py: 0.4,
|
||||
color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover .ev-title': { color: accent },
|
||||
}}>
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
|
||||
{events.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
|
||||
)}
|
||||
{events.map((e, idx) => (
|
||||
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||
py: 0.4,
|
||||
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover .ev-title': { color: accent },
|
||||
}}>
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
{hasMore && <Box ref={listSentinelRef} sx={{ height: 1 }} />}
|
||||
);
|
||||
})}
|
||||
{windowing.bottomSpacer > 0 && (
|
||||
<Box aria-hidden sx={{ height: windowing.bottomSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
|
||||
)}
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Generic list windowing, lifted from AgentChat's transcript virtualizer so a
|
||||
// long schedule list mounts only the rows near the viewport (off-screen rows
|
||||
// unmount, replaced by measured-height spacers). Rows can be any height; the
|
||||
// hook measures them once on screen and estimates the rest. Top-anchored: the
|
||||
// list reads from the top, no bottom-following like the chat does.
|
||||
|
||||
// Keep this many screens of real content mounted on EACH side of the viewport.
|
||||
const BUFFER_SCREENS_PER_SIDE = 3;
|
||||
// Floor on mounted count so one very tall row can't strand an empty window.
|
||||
const MIN_BUFFER_ITEMS = 2;
|
||||
|
||||
// Pure solver: given scroll position and a per-index height accessor (measured
|
||||
// where known, estimated otherwise), return the [start, end) slice to mount.
|
||||
// Buffer is in PIXELS (N screens per side), so a few tall rows can't blow the
|
||||
// mounted set up to the whole list.
|
||||
export function computeDesiredWindow(
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
total: number,
|
||||
heightOf: (index: number) => number,
|
||||
bufferPx: number,
|
||||
): { start: number; end: number } {
|
||||
if (total <= 0) return { start: 0, end: 0 };
|
||||
const keepTop = scrollTop - bufferPx;
|
||||
const keepBottom = scrollTop + clientHeight + bufferPx;
|
||||
let offset = 0;
|
||||
let start = -1;
|
||||
let end = total;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const h = heightOf(i);
|
||||
const itemTop = offset;
|
||||
const itemBottom = offset + h;
|
||||
if (start === -1 && itemBottom > keepTop) start = i;
|
||||
if (itemTop < keepBottom) {
|
||||
end = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
offset += h;
|
||||
}
|
||||
if (start === -1) start = Math.max(0, total - 1);
|
||||
end = Math.min(total, Math.max(end, start + 1));
|
||||
if (end - start < MIN_BUFFER_ITEMS) {
|
||||
start = Math.max(0, Math.min(start, end - MIN_BUFFER_ITEMS));
|
||||
}
|
||||
return { start: Math.max(0, start), end };
|
||||
}
|
||||
|
||||
interface UseWindowedListArgs {
|
||||
// Stable id per row, in render order. Heights are cached by id so a measured
|
||||
// row keeps its height across re-renders even as the window slides.
|
||||
ids: string[];
|
||||
estimateHeight: (index: number) => number;
|
||||
// Off below this gates windowing entirely: render all, no spacers. Short
|
||||
// lists don't benefit and the spacer recompute just fights the scrollbar.
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseWindowedListResult {
|
||||
setScrollEl: (el: HTMLDivElement | null) => void;
|
||||
onScroll: () => void;
|
||||
start: number;
|
||||
end: number;
|
||||
topSpacer: number;
|
||||
bottomSpacer: number;
|
||||
}
|
||||
|
||||
export function useWindowedList({ ids, estimateHeight, enabled }: UseWindowedListArgs): UseWindowedListResult {
|
||||
const total = ids.length;
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const heightsRef = useRef<Map<string, number>>(new Map());
|
||||
const [heightVersion, setHeightVersion] = useState(0);
|
||||
|
||||
const [start, setStart] = useState(0);
|
||||
const [end, setEnd] = useState(total);
|
||||
const startRef = useRef(0);
|
||||
const endRef = useRef(total);
|
||||
|
||||
const idsRef = useRef(ids);
|
||||
idsRef.current = ids;
|
||||
const estimateRef = useRef(estimateHeight);
|
||||
estimateRef.current = estimateHeight;
|
||||
|
||||
const heightOf = useCallback((index: number): number => {
|
||||
const id = idsRef.current[index];
|
||||
if (id == null) return 0;
|
||||
const measured = heightsRef.current.get(id);
|
||||
if (measured != null) return measured;
|
||||
return estimateRef.current(index);
|
||||
}, []);
|
||||
|
||||
const applyWindow = useCallback(() => {
|
||||
const el = scrollEl;
|
||||
if (!el || !enabled) return;
|
||||
const count = idsRef.current.length;
|
||||
const clientHeight = Math.max(1, el.clientHeight);
|
||||
const tightPx = BUFFER_SCREENS_PER_SIDE * clientHeight;
|
||||
const loosePx = tightPx + clientHeight;
|
||||
const tight = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, tightPx);
|
||||
const loose = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, loosePx);
|
||||
const curStart = startRef.current;
|
||||
const curEnd = endRef.current;
|
||||
// Hysteresis: must-mount the tight band, but keep already-mounted edges
|
||||
// until they drift past the looser band, so rows on the boundary don't
|
||||
// flip-flop mount/unmount on every scroll tick.
|
||||
let next = Math.max(loose.start, Math.min(curStart, tight.start));
|
||||
let nextEnd = Math.min(loose.end, Math.max(curEnd, tight.end));
|
||||
next = Math.max(0, Math.min(next, Math.max(0, nextEnd - 1)));
|
||||
if (next === curStart && nextEnd === curEnd) return;
|
||||
startRef.current = next;
|
||||
endRef.current = nextEnd;
|
||||
setStart(next);
|
||||
setEnd(nextEnd);
|
||||
}, [scrollEl, enabled, heightOf]);
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const onScroll = useCallback(() => {
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
applyWindow();
|
||||
});
|
||||
}, [applyWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollEl) return;
|
||||
applyWindow();
|
||||
const obs = new ResizeObserver(() => applyWindow());
|
||||
obs.observe(scrollEl);
|
||||
return () => {
|
||||
obs.disconnect();
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [scrollEl, enabled, total, heightVersion, applyWindow]);
|
||||
|
||||
// Measure mounted rows after paint; a real height replaces its estimate and
|
||||
// nudges the window + spacers to the truth on the next frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!scrollEl) return;
|
||||
let changed = false;
|
||||
scrollEl.querySelectorAll<HTMLElement>('[data-wl-id]').forEach((node) => {
|
||||
const id = node.dataset.wlId;
|
||||
if (!id) return;
|
||||
const h = node.offsetHeight;
|
||||
if (h <= 0) return;
|
||||
const prev = heightsRef.current.get(id);
|
||||
if (prev === undefined || Math.abs(prev - h) > 1) {
|
||||
heightsRef.current.set(id, h);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) setHeightVersion((v) => v + 1);
|
||||
});
|
||||
|
||||
const safeStart = enabled ? Math.min(Math.max(0, start), Math.max(0, total - 1)) : 0;
|
||||
const safeEnd = enabled ? Math.min(Math.max(safeStart + 1, end), total) : total;
|
||||
|
||||
const { topSpacer, bottomSpacer } = useMemo(() => {
|
||||
if (!enabled) return { topSpacer: 0, bottomSpacer: 0 };
|
||||
let top = 0;
|
||||
for (let i = 0; i < safeStart; i++) top += heightOf(i);
|
||||
let bottom = 0;
|
||||
for (let i = safeEnd; i < total; i++) bottom += heightOf(i);
|
||||
return { topSpacer: top, bottomSpacer: bottom };
|
||||
// heightVersion: spacers depend on the measured-height map (a ref the dep
|
||||
// checker can't see), recompute when a measurement lands.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, safeStart, safeEnd, total, heightVersion, heightOf]);
|
||||
|
||||
return { setScrollEl, onScroll, start: safeStart, end: safeEnd, topSpacer, bottomSpacer };
|
||||
}
|
||||
Reference in New Issue
Block a user