diff --git a/apps/client/src/features/base/components/base-table.tsx b/apps/client/src/features/base/components/base-table.tsx index e1b31949e..16acdbbf7 100644 --- a/apps/client/src/features/base/components/base-table.tsx +++ b/apps/client/src/features/base/components/base-table.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { Text, Stack } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import { useAtom } from "jotai"; @@ -151,6 +151,8 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) { clearSelection(); }, [pageId, activeView?.id, clearSelection]); + const scrollportRef = useRef(null); + const rows = useMemo(() => { const flat = flattenRows(rowsData); // When a sort is active, the server returns rows in the requested @@ -314,61 +316,76 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) { if (!base) return null; - // When embedded inline in a doc page, the parent - // exposes --embed-extend-l / --embed-extend-r (positive px). We - // pull both edges outward via negative margin so the scroll viewport - // grows toward AppShell.Main's edges. Initial visual alignment with - // page text is preserved by --embed-grid-pad-left, applied to the - // .grid in grid.module.css — that padding makes the first cell sit - // at page-content-left on load while still letting the user pan - // left into the extended viewport. Toolbar is unchanged. - const gridExtendStyle = embedded - ? ({ - marginLeft: "calc(-1 * var(--embed-extend-l, 0px))", - marginRight: "calc(-1 * var(--embed-extend-r, 0px))", - } as const) - : undefined; + const banner = ( + + ); + const toolbar = ( + + ); + + const grid = ( + + {banner} + {toolbar} + + ) : null + } + /> + ); + + if (embedded) { + // Inline: banner + toolbar live inside the StickyBand (passed via + // stickyBandPrelude). The page is the vertical scroll container. + return grid; + } + + // Standalone: banner + toolbar sit above the .tableScrollport, which + // is the vertical scroll container. StickyBand inside contains only + // the column-header row. return (
- - -
- + {banner} + {toolbar} +
+ {grid}
); diff --git a/apps/client/src/features/base/components/grid/grid-cell.tsx b/apps/client/src/features/base/components/grid/grid-cell.tsx index c4f35bdba..6f7e11dd4 100644 --- a/apps/client/src/features/base/components/grid/grid-cell.tsx +++ b/apps/client/src/features/base/components/grid/grid-cell.tsx @@ -135,9 +135,11 @@ export const GridCell = memo(function GridCell({ return (
; properties: IBaseProperty[]; @@ -44,6 +60,19 @@ type GridContainerProps = { hasNextPage?: boolean; isFetchingNextPage?: boolean; onFetchNextPage?: () => void; + /** + * What the virtualizer measures and what the StickyBand sticks to. + * Standalone passes a ref into the .tableScrollport wrapper; inline + * passes `window` since the page itself is the scroll container. + */ + scrollElement: HTMLElement | Window | null; + /** + * Rendered above the column-header row inside the StickyBand. In + * inline mode BaseTable injects banner + toolbar here so they stick + * alongside the headers; in standalone this is null (banner + + * toolbar render outside the scrollport). + */ + stickyBandPrelude?: React.ReactNode; }; export function GridContainer({ @@ -58,8 +87,12 @@ export function GridContainer({ hasNextPage, isFetchingNextPage, onFetchNextPage, + scrollElement, + stickyBandPrelude, }: GridContainerProps) { - const scrollRef = useRef(null); + const headerRef = useRef(null); + const bodyRef = useRef(null); + useHorizontalScrollSync(bodyRef, headerRef); const lastTriggeredRowsLenRef = useRef(0); const rows = table.getRowModel().rows; @@ -100,14 +133,55 @@ export function GridContainer({ table, editingCell, setEditingCell, - containerRef: scrollRef, + containerRef: bodyRef, }); + // When the scroll container is the window (inline embed mode), + // useVirtualizer's default Element-mode observers read scrollTop / + // scrollLeft — properties Window doesn't have. Swap in the Window- + // mode observers so the virtualizer reads scrollY / scrollX instead. + // The Element-narrowed type signature is satisfied by an upcast on + // getScrollElement: virtual-core's runtime accepts Window when the + // observers do. + const isWindowScroll = + typeof window !== "undefined" && scrollElement === window; + const windowScrollOptions = isWindowScroll ? WINDOW_SCROLL_OPTIONS : {}; + + // Window-mode virtualizer reads window.scrollY as offset, but rows + // are positioned within .bodyGrid which sits at some non-zero Y in + // the document (below banner/toolbar/upstream page content). Pass + // scrollMargin = bodyGrid's document-relative top so the virtualizer + // indexes correctly. Re-measure on resize via ResizeObserver — the + // embed extension logic in BaseEmbedView already triggers layout + // changes that we need to react to. + const [scrollMargin, setScrollMargin] = useState(0); + useLayoutEffect(() => { + if (!isWindowScroll) return; + const el = bodyRef.current; + if (!el) return; + const update = () => { + const rect = el.getBoundingClientRect(); + setScrollMargin(rect.top + window.scrollY); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + // Outer page reflows (sidebar collapse, viewport resize) move the + // embed without resizing it — listen to window resize too. + window.addEventListener("resize", update); + return () => { + ro.disconnect(); + window.removeEventListener("resize", update); + }; + }, [isWindowScroll]); + const virtualizer = useVirtualizer({ count: rows.length, - getScrollElement: () => scrollRef.current, + getScrollElement: () => scrollElement as Element | null, estimateSize: () => ROW_HEIGHT, overscan: OVERSCAN, + scrollMargin, + ...windowScrollOptions, }); const virtualItems = virtualizer.getVirtualItems(); @@ -133,7 +207,7 @@ export function GridContainer({ }, [rows.length]); useEffect(() => { - const el = scrollRef.current; + const el = bodyRef.current; if (!el || !pageId) return; const handler = (e: KeyboardEvent) => { if (editingCell) return; @@ -164,8 +238,18 @@ export function GridContainer({ const totalHeight = virtualizer.getTotalSize(); + // virtual-core bakes `scrollMargin` into both `start`/`end` and + // `getTotalSize()`. We render padding spacers inside .bodyGrid to + // position rows in the grid flow, so paddingTop must be relative to + // .bodyGrid's own top — subtract scrollMargin out of items[0].start + // (which would otherwise push the first row down by the full embed + // offset, leaving a giant blank gap above the data). totalHeight + // already includes scrollMargin, so paddingBottom needs no + // adjustment. const paddingTop = - virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0; + virtualItems.length > 0 + ? Math.max(0, (virtualItems[0]?.start ?? 0) - scrollMargin) + : 0; const paddingBottom = virtualItems.length > 0 ? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0) @@ -196,8 +280,8 @@ export function GridContainer({ // Wait for React to re-render with the new column, then scroll to it requestAnimationFrame(() => { requestAnimationFrame(() => { - scrollRef.current?.scrollTo({ - left: scrollRef.current.scrollWidth, + bodyRef.current?.scrollTo({ + left: bodyRef.current.scrollWidth, behavior: "smooth", }); }); @@ -236,35 +320,41 @@ export function GridContainer({ onDragEnd={handleDragEnd} modifiers={modifiers} > -
-
- +
+ {stickyBandPrelude} +
- - - + + + +
+
+
{paddingTop > 0 && (
)} - {virtualItems.map((virtualRow) => { const row = rows[virtualRow.index]; if (!row) return null; @@ -292,13 +382,12 @@ export function GridContainer({ /> ); })} - {paddingBottom > 0 && (
)} + + {pageId && }
- - {pageId && }
); diff --git a/apps/client/src/features/base/components/grid/grid-header-cell.tsx b/apps/client/src/features/base/components/grid/grid-header-cell.tsx index 8331000c7..62bd454b3 100644 --- a/apps/client/src/features/base/components/grid/grid-header-cell.tsx +++ b/apps/client/src/features/base/components/grid/grid-header-cell.tsx @@ -145,7 +145,9 @@ export const GridHeaderCell = memo(function GridHeaderCell({ ref={combinedRef} className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`} style={{ - ...(isPinned ? { left: pinOffset } : {}), + ...(isPinned + ? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties) + : {}), ...(isRowNumber ? {} : { cursor: "pointer" }), ...sortableStyle, }} diff --git a/apps/client/src/features/base/components/grid/row-number-cell.tsx b/apps/client/src/features/base/components/grid/row-number-cell.tsx index 8c1144257..4671cf90c 100644 --- a/apps/client/src/features/base/components/grid/row-number-cell.tsx +++ b/apps/client/src/features/base/components/grid/row-number-cell.tsx @@ -44,7 +44,11 @@ export const RowNumberCell = memo(function RowNumberCell({ return (
Math.abs(e.deltaY)) return; if (e.deltaY === 0) return; + // Suppress the browser's default vertical scroll on the page + // (or the standalone scrollport) — we're consuming this wheel + // event as a horizontal pan, not a vertical scroll. Requires a + // non-passive listener (configured below). + e.preventDefault(); body.scrollLeft += e.deltaY; }; body.addEventListener("scroll", onBodyScroll, { passive: true }); - header.addEventListener("wheel", onHeaderWheel, { passive: true }); + header.addEventListener("wheel", onHeaderWheel, { passive: false }); // Initial sync — covers the case where body is already scrolled // when the hook mounts (e.g. after a route change). diff --git a/apps/client/src/features/base/styles/grid.module.css b/apps/client/src/features/base/styles/grid.module.css index 5007bc43f..0fd3d9716 100644 --- a/apps/client/src/features/base/styles/grid.module.css +++ b/apps/client/src/features/base/styles/grid.module.css @@ -24,14 +24,18 @@ * .tableScrollport in standalone, to the page in inline. The * --sticky-band-top var is set on inline embed wrappers to clear * the fixed PageHeader; standalone leaves it unset (resolves to 0 - * relative to the scrollport). */ + * relative to the scrollport). + * + * Background uses --mantine-color-body (the doc bg) rather than the + * gray-0 token. The headers themselves carry their own gray bg via + * .headerCell, so the band looks like: doc-color strip behind + * banner+toolbar (matches the surrounding doc in inline mode), gray + * row at the headers. The band still needs its own bg so rows don't + * show through the 1-px gaps between header cells when scrolling. */ position: sticky; top: var(--sticky-band-top, 0); z-index: 10; - background-color: light-dark( - var(--mantine-color-gray-0), - var(--mantine-color-dark-6) - ); + background-color: var(--mantine-color-body); border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); } @@ -44,6 +48,13 @@ * by useHorizontalScrollSync to mirror the body. Inline embeds extend * the visible width into the AppShell margins via --embed-extend-*; * standalone leaves those vars unset (calc resolves to 0). */ + /* No `min-width: max-content`: this element IS the horizontal + * scrollport (overflow-x: hidden, scrollLeft mirrored via JS), so we + * want its own width to follow the parent. If `min-width: max-content` + * were set here, the grid box itself would grow to fit all tracks + * and propagate width up to AppShell, triggering page-level + * horizontal scroll. Tracks wider than the box are handled by the + * grid's own overflow-x. */ display: grid; overflow-x: hidden; margin-left: calc(-1 * var(--embed-extend-l, 0px)); @@ -56,74 +67,29 @@ /* Same shape as .headerGrid, but overflow-x: auto so the horizontal * scrollbar lives here. Vertical scroll is owned by .tableScrollport * (standalone) or the page (inline) — there's no internal vertical - * scroll on .bodyGrid in either mode. */ + * scroll on .bodyGrid in either mode. Same `min-width` argument + * applies as in .headerGrid: leave it unset so this element's box + * matches its parent and tracks-overflow is contained by the grid's + * own overflow-x: auto. */ display: grid; overflow-x: auto; margin-left: calc(-1 * var(--embed-extend-l, 0px)); margin-right: calc(-1 * var(--embed-extend-r, 0px)); padding-left: var(--embed-grid-pad-left, 0); padding-right: var(--embed-grid-pad-right, 0); - /* Match the existing .gridWrapper bottom-padding so the AddRowButton - * keeps clear of the horizontal scrollbar. */ + /* Bottom-padding so the AddRowButton keeps clear of the horizontal + * scrollbar that lives on this element. */ padding-bottom: 6px; } -.gridWrapper { - position: relative; - overflow: auto; - overflow-anchor: none; - flex: 1; - min-height: 0; - padding-left: 6px; - /* Reserve space below the AddRowButton so the horizontal scrollbar - * doesn't overlap it. */ - padding-bottom: 6px; -} - -.grid { - display: grid; - min-width: max-content; - /* Outer border + radius is the panel-style framing for the standalone - * full-page base. Inline embeds override these vars to drop the outer - * frame so the table reads as part of the document — cell-level - * borders below still provide the gridline separators. */ - border: var( - --grid-outer-border, - 1px solid - light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)) - ); - border-radius: var(--grid-outer-radius, var(--mantine-radius-sm)); - /* When the embed wrapper extends the scroll viewport leftward, this - * padding pushes the first cell back to page-content alignment so - * the table looks aligned on load. The padded area is part of the - * scrollable region — the user can pan left into it. Standalone - * full-page bases never set the var, so it's a no-op there. */ - padding-left: var(--embed-grid-pad-left, 0); - /* Symmetric right-side padding lets the user scroll past the last - * column into empty space, so wide tables don't end abruptly at - * the viewport edge. */ - padding-right: var(--embed-grid-pad-right, 0); -} - .headerRow { /* `display: contents` removes the wrapper from layout while keeping the - * `role="row"` for accessibility. Header cells become direct grid items - * of `.grid`, so their containing block is the full-height table — a - * prerequisite for `position: sticky` on `.headerCell` to actually - * travel the length of the scroll. With a subgrid wrapper here, sticky - * was constrained to the 34px header row and scrolled away. */ + * `role="row"` for accessibility. Header cells become direct grid + * items of `.headerGrid`, so they pick up its column tracks. */ display: contents; } .headerCell { - /* Sticky to the top of the gridWrapper scroll viewport so the column - * header row stays visible while the user scrolls rows underneath. - * z-index 2 lifts it above non-pinned body cells (default 0); the - * pinned variant below bumps to 3 so the corner cell stays above - * pinned body cells (z-index 1) at the top-left intersection. */ - position: sticky; - top: 0; - z-index: 2; display: flex; align-items: center; gap: 6px; @@ -136,8 +102,6 @@ var(--mantine-color-gray-0), var(--mantine-color-dark-6) ); - border-bottom: 1px solid - light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); border-right: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); user-select: none; @@ -152,11 +116,28 @@ } .headerCellPinned { - /* Sticks both vertically (inherited top: 0) and horizontally (left - * offset set inline by tanstack-table's column-pinning), so the row- - * number / primary-property column stays visible at the top-left - * corner regardless of scroll axis. */ - z-index: 3; + /* Pinned header cells stick within .headerGrid's scrollport. The + * `left` value is the sticky anchor relative to the scrollport's + * content edge. + * + * Match Notion's freeze behavior: at scrollLeft=0, the pinned column + * sits at its natural position (the page-content edge); as the user + * scrolls horizontally, the column scrolls along with the rest of + * the grid; once it reaches the AppShell-margin edge (the scrollport + * padding edge) it stops and stays there while non-pinned columns + * keep scrolling past behind it. + * + * To make the column travel that --embed-grid-pad-left distance + * before sticking, we anchor at `-padding-left` from the natural + * sticky position. In standalone the var is unset → calc collapses + * to `var(--pin-offset, 0px)` and the column sticks immediately at + * its track position, which is what standalone wants since there's + * no extension to travel through. --pin-offset is set inline by + * GridHeaderCell from TanStack's column.getStart("left"), giving + * each pinned column its accumulated-width offset. */ + position: sticky; + left: calc(-1 * var(--embed-grid-pad-left, 0px) + var(--pin-offset, 0px)); + z-index: 2; background-color: light-dark( var(--mantine-color-gray-0), var(--mantine-color-dark-6) @@ -259,7 +240,11 @@ } .cellPinned { + /* See .headerCellPinned for the rationale on the negative pad-left + * — both grids share the same anchor calculation so header and body + * cells freeze in lockstep. */ position: sticky; + left: calc(-1 * var(--embed-grid-pad-left, 0px) + var(--pin-offset, 0px)); z-index: 1; background-color: light-dark( var(--mantine-color-white), @@ -316,12 +301,17 @@ } .addRowButton { - /* Inline-flex + width:max-content so the button only takes the space - * it needs and stays anchored to the page-content edge during the - * horizontal scroll, instead of riding along with the grid as a - * full-row item. --embed-grid-pad-left is the leftward extension - * distance in embed mode (sticks at page-content-left), 0 in - * standalone (sticks at gridWrapper's natural left). */ + /* AddRowButton is a child of .bodyGrid (which is a CSS grid). Without + * `grid-column: 1 / -1` it would auto-place into track 1 of a new + * grid row, leaving phantom empty cells in the remaining tracks of + * that row. Spanning all tracks gives it a single full-row grid area + * while inline-flex + width:max-content + sticky-left keeps the + * visible button shrunk to its content and anchored to the page- + * content edge during horizontal scroll. --embed-grid-pad-left is + * the leftward extension distance in embed mode (sticks at page- + * content-left), 0 in standalone (sticks at .bodyGrid's natural + * left). */ + grid-column: 1 / -1; display: inline-flex; align-items: center; gap: 6px; @@ -353,12 +343,6 @@ } .addColumnButton { - /* Sits at the trailing edge of the header row — match the sticky-top - * behaviour of `.headerCell` so it doesn't drift away when the grid - * scrolls vertically. */ - position: sticky; - top: 0; - z-index: 2; display: flex; align-items: center; justify-content: center; diff --git a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx index 9b87092b1..1a78a0d0d 100644 --- a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx +++ b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx @@ -32,10 +32,12 @@ function applyExtension(wrapper: HTMLDivElement) { // column into empty space — same behaviour as Notion, gives the // table breathing room on the right when scrolled fully right. wrapper.style.setProperty("--embed-grid-pad-right", `${extendRight}px`); - // Drop the standalone "panel" frame: inline databases read as part - // of the document, with only cell separators as gridlines. - wrapper.style.setProperty("--grid-outer-border", "none"); - wrapper.style.setProperty("--grid-outer-radius", "0"); + // Inline sticky band clears the fixed PageHeader. Standalone leaves + // the var unset (resolves to the rule default of 0). + wrapper.style.setProperty( + "--sticky-band-top", + "var(--page-header-height)", + ); } export function BaseEmbedView({ node }: NodeViewProps) {