commit 3425e755790b87640a5e7091dbf09a686957e967
parent 284472d2feea6d80ca1885c693ca3cadb6c61250
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 22 May 2026 13:08:16 -0400
fix virtual list gaps
Diffstat:
1 file changed, 64 insertions(+), 31 deletions(-)
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -1508,7 +1508,16 @@ type ResultRow =
hitIndex: number;
isFirstInCard: false;
isLastInCard: boolean;
- };
+ }
+ // A dedicated 12px transparent row that sits between cards. Doing the
+ // gap as its own virtualized row (rather than padding-bottom on the
+ // previous card's last row) means the row that "owns" the gap has its
+ // height pinned to a constant — the virtualizer never has to discover
+ // it through ResizeObserver, so the gap stays put even before the
+ // dynamic-measurement pass completes.
+ | { kind: "gap"; cardIndex: number };
+
+const GAP_ROW_PX = 12;
function buildResultRows(
resultGroups: ReadonlyArray<ResultGroup>,
@@ -1554,15 +1563,19 @@ function buildResultRows(
});
});
}
- // Flag the last row of this card so the renderer can close its visual
- // grouping (rounded-b, border-b, margin to the next card).
+ // Mark the last content row of this card so the renderer can apply
+ // the rounded-b / border-b. Tombstone cards (header only) get the
+ // header marked.
const last = out[out.length - 1];
- if (last && last.cardIndex === cardIndex && out.length - 1 > cardStart) {
- (last as { isLastInCard: boolean }).isLastInCard = true;
- } else if (last && out.length - 1 === cardStart) {
- // Card with no contributing hits — header is also the last row.
+ if (last && last.kind !== "gap" && last.cardIndex === cardIndex) {
(last as { isLastInCard: boolean }).isLastInCard = true;
}
+ // Inter-card gap, skipped after the final card so there's no
+ // trailing empty space below the result list.
+ if (cardIndex < resultGroups.length - 1) {
+ out.push({ kind: "gap", cardIndex });
+ }
+ void cardStart;
});
return out;
}
@@ -1575,6 +1588,8 @@ function rowKey(row: ResultRow): string {
return `c${row.cardIndex}:l:${row.leafId}`;
case "hit":
return `c${row.cardIndex}:h:${row.leafId}:${row.hitIndex}`;
+ case "gap":
+ return `c${row.cardIndex}:gap`;
}
}
@@ -1621,7 +1636,11 @@ function VirtualResultList({
const rowVirtualizer = useWindowVirtualizer({
count: flatRows.length,
- estimateSize: () => RESULT_ROW_ESTIMATE_PX,
+ // The estimate only matters until ResizeObserver measures each row.
+ // Picking a value close to the most common row height (transcript
+ // hits + leaf-headers) keeps any pre-measurement layout shift small.
+ estimateSize: (index) =>
+ flatRows[index]?.kind === "gap" ? GAP_ROW_PX : RESULT_ROW_ESTIMATE_PX,
overscan: 12,
scrollMargin,
});
@@ -1637,30 +1656,39 @@ function VirtualResultList({
>
{virtualItems.map((vi) => {
const row = flatRows[vi.index];
- // The outer (positioning) wrapper carries `pb-3` on last-in-card
- // rows so the next card starts 12px lower. Margins are no good
- // here — sibling absolutely-positioned rows ignore them, but the
- // virtualizer's ResizeObserver picks up padding as part of the
- // measured row height, which pushes subsequent translateY values.
- // The inner div holds the actual visual frame (border + bg +
- // rounded corners) so the gap appears OUTSIDE the card.
+ if (row.kind === "gap") {
+ // Fixed-height transparent row. Putting the frame classes on
+ // the same div the virtualizer measures (rather than wrapping
+ // an inner frame div) avoids a stale-measurement class of
+ // bugs where the gap "appeared" between rows until a scroll
+ // forced re-measurement.
+ return (
+ <VirtualRow
+ key={rowKey(row)}
+ measureRef={rowVirtualizer.measureElement}
+ index={vi.index}
+ offset={vi.start - scrollMargin}
+ className=""
+ >
+ <div style={{ height: GAP_ROW_PX }} aria-hidden="true" />
+ </VirtualRow>
+ );
+ }
return (
<VirtualRow
key={rowKey(row)}
measureRef={rowVirtualizer.measureElement}
index={vi.index}
offset={vi.start - scrollMargin}
- className={row.isLastInCard ? "pb-3" : ""}
+ className={rowFrameClass(row)}
>
- <div className={rowFrameClass(row)}>
- <ResultRowView
- row={row}
- leavesById={leavesById}
- activeVideo={activeVideo}
- activeTime={activeTime}
- openWithMode={openWithMode}
- />
- </div>
+ <ResultRowView
+ row={row}
+ leavesById={leavesById}
+ activeVideo={activeVideo}
+ activeTime={activeTime}
+ openWithMode={openWithMode}
+ />
</VirtualRow>
);
})}
@@ -1668,12 +1696,17 @@ function VirtualResultList({
);
}
-// Tailwind classes for the inner card-frame div. Side borders on every row,
-// plus a top border on every non-first row, give the divide-y effect; the
-// first and last rows of a card carry the rounded corners.
-function rowFrameClass(row: ResultRow): string {
+// Tailwind classes for the row's outer (virtualized + measured) div. Side
+// borders on every row plus a top border on every non-first row of a card
+// give the divide-y effect; the first and last rows of a card carry the
+// rounded corners. The frame lives on the measured div directly so the
+// virtualizer's ResizeObserver sees the row's true height without an
+// intermediate wrapper.
+function rowFrameClass(
+ row: Exclude<ResultRow, { kind: "gap" }>,
+): string {
const base =
- "border-l border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900";
+ "border-l border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 overflow-hidden";
const top = row.isFirstInCard
? "rounded-t-lg border-t"
: "border-t border-zinc-200/60 dark:border-zinc-800/60";
@@ -1688,7 +1721,7 @@ function ResultRowView({
activeTime,
openWithMode,
}: {
- row: ResultRow;
+ row: Exclude<ResultRow, { kind: "gap" }>;
leavesById: ReadonlyMap<string, LeafInfo>;
activeVideo: string | null;
activeTime: number | null;