# ankurah-virtual-scroll

Platform-agnostic virtual scroll state machine with pagination for Ankurah.

## Overview

`ankurah-virtual-scroll` provides smooth infinite scrolling through database-backed lists without loading everything into memory. It maintains a sliding window of items, expanding or sliding the window as the user scrolls, while preserving scroll position stability through intersection anchoring.

## Features

- **Bidirectional pagination**: Load older and newer content seamlessly
- **Scroll position stability**: Maintain scroll position when loading new items via intersection anchoring
- **Reactive integration**: Works with Ankurah's LiveQuery for real-time updates
- **Platform-agnostic**: Core logic in Rust with WASM bindings (UniFFI in development)
- **Variable item heights**: Handles items of different sizes correctly

## Installation

```toml
[dependencies]
ankurah-virtual-scroll = "0.7"
```

## Usage

### Leptos / Dioxus (Pure Rust)

Use `ScrollManager<V>` directly - no macro needed:

<pre><code transclude="crates/virtual-scroll/tests/readme_example.rs#rust-usage">/// Example: Creating and using a ScrollManager
#[allow(dead_code)]
async fn scroll_manager_example() -&gt; Result&lt;(), Box&lt;dyn std::error::Error&gt;&gt; {
    let ctx = durable_sled_setup().await?;

    // Create scroll manager with full configuration
    let scroll_manager = ScrollManager::&lt;TestMessageView&gt;::new(
        &amp;ctx,
        &quot;true&quot;,              // Filter predicate (e.g., &quot;room = &#39;general&#39;&quot;)
        &quot;timestamp DESC&quot;,    // Display order
        40,                  // Minimum row height (pixels)
        2.0,                 // Buffer factor (2.0 = 2x viewport)
        600,                 // Viewport height (pixels)
    )?;

    // Initialize (runs initial query)
    scroll_manager.start().await;

    // Read visible items from the signal
    let visible_set = scroll_manager.visible_set().get();
    for item in &amp;visible_set.items {
        // Access item fields via the View trait
        let _id = item.entity().id();
    }

    // Notify on scroll events with first/last visible EntityIds
    if let (Some(first), Some(last)) = (visible_set.items.first(), visible_set.items.last()) {
        let first_visible_id = first.entity().id();
        let last_visible_id = last.entity().id();
        let scrolling_backward = true; // true = scrolling toward older items

        scroll_manager.on_scroll(first_visible_id, last_visible_id, scrolling_backward);
    }

    Ok(())
}</code></pre>

### React Web (WASM) / React Native (UniFFI)

For JavaScript/TypeScript frontends, use the `generate_scroll_manager!` macro in your bindings crate to generate platform-specific wrappers:

<pre><code transclude="playwright-tests/wasm-bindings/src/lib.rs#generate-macro">// Generate MessageScrollManager with WASM bindings
ankurah_virtual_scroll::generate_scroll_manager!(
    Message,
    MessageView,
    MessageLiveQuery,
    timestamp_field = &quot;timestamp&quot;
);</code></pre>

This generates `MessageScrollManager` with the appropriate bindings based on feature flags:
- `wasm` feature: generates `#[wasm_bindgen]` bindings for React web apps
- `uniffi` feature: generates UniFFI bindings for React Native apps (in development)

#### React Component Example

<pre><code transclude="playwright-tests/react-app/src/components/ExampleMessageList.tsx#react-example">import { useEffect, useRef, useState, useCallback, useMemo } from &#39;react&#39;

// Types from WASM bindings (generated by ankurah-virtual-scroll-derive)
interface MessageView {
  id: () =&gt; { toString: () =&gt; string }
  text: () =&gt; string
}
interface MessageVisibleSet {
  items: MessageView[]
}
interface MessageVisibleSetSignal {
  get: () =&gt; MessageVisibleSet
}
interface MessageScrollManager {
  start: () =&gt; Promise&lt;void&gt;
  visibleSet: () =&gt; MessageVisibleSetSignal
  onScroll: (firstVisible: string, lastVisible: string, scrollingBackward: boolean) =&gt; void
}

// These would come from your WASM bindings package
declare function ctx(): unknown
declare const MessageScrollManager: new (
  ctx: unknown,
  predicate: string,
  orderBy: string,
  minRowHeight: number,
  bufferFactor: number,
  viewportHeight: number
) =&gt; MessageScrollManager

const VIEWPORT_HEIGHT = 400
const MIN_ROW_HEIGHT = 40

export function ExampleMessageList({ roomId }: { roomId: string }) {
  const containerRef = useRef&lt;HTMLDivElement&gt;(null)
  const lastScrollTop = useRef(0)
  const [items, setItems] = useState&lt;MessageView[]&gt;([])

  // Create scroll manager once per room
  const manager = useMemo(() =&gt; {
    return new MessageScrollManager(
      ctx(),
      `room = &#39;${roomId}&#39;`,
      &#39;timestamp DESC&#39;,
      MIN_ROW_HEIGHT,
      2.0,
      VIEWPORT_HEIGHT
    )
  }, [roomId])

  // Initialize and sync state on mount
  useEffect(() =&gt; {
    manager.start().then(() =&gt; {
      const vs = manager.visibleSet().get()
      setItems([...vs.items])
    })
  }, [manager])

  // Find first/last visible items by checking DOM element positions
  const findVisibleItems = useCallback(() =&gt; {
    const container = containerRef.current
    if (!container) return null

    const elements = container.querySelectorAll(&#39;[data-item-id]&#39;)
    let firstId: string | null = null
    let lastId: string | null = null

    elements.forEach(el =&gt; {
      const rect = el.getBoundingClientRect()
      const containerRect = container.getBoundingClientRect()
      // Item is visible if it overlaps with container viewport
      if (rect.bottom &gt; containerRect.top &amp;&amp; rect.top &lt; containerRect.bottom) {
        const id = el.getAttribute(&#39;data-item-id&#39;)
        if (id) {
          if (!firstId) firstId = id
          lastId = id
        }
      }
    })

    return firstId &amp;&amp; lastId ? { firstId, lastId } : null
  }, [])

  // Handle scroll events - detect direction and notify scroll manager
  const handleScroll = useCallback((e: React.UIEvent&lt;HTMLDivElement&gt;) =&gt; {
    const el = e.currentTarget
    const scrollingBackward = el.scrollTop &lt; lastScrollTop.current
    lastScrollTop.current = el.scrollTop

    const visible = findVisibleItems()
    if (visible) {
      // Pass EntityId strings (not pixel values) to scroll manager
      manager.onScroll(visible.firstId, visible.lastId, scrollingBackward)
      // Sync state after potential window slide
      const vs = manager.visibleSet().get()
      setItems([...vs.items])
    }
  }, [manager, findVisibleItems])

  return (
    &lt;div
      ref={containerRef}
      onScroll={handleScroll}
      style={{ height: VIEWPORT_HEIGHT, overflowY: &#39;auto&#39; }}
    &gt;
      {items.map(msg =&gt; (
        &lt;div key={msg.id().toString()} data-item-id={msg.id().toString()}&gt;
          {msg.text()}
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  )
}</code></pre>

## Modes

- **Live**: At the newest edge, receiving real-time updates with auto-scroll
- **Backward**: User scrolled toward older items, loading historical content
- **Forward**: User scrolling back toward newer items, transitions to Live when reaching the edge

## Architecture

The scroll manager handles:
- Query construction (predicate + cursor + ordering + limit)
- Mode tracking (Live / Backward / Forward)
- Boundary detection (at earliest/latest based on result count)
- Intersection anchoring for scroll stability

Platform layers handle:
- DOM/FlatList binding and scroll events
- Visible item detection (by EntityId)
- Scroll position measurement and adjustment

## Crates

- `ankurah-virtual-scroll` - Core scroll manager implementation
- `ankurah-virtual-scroll-derive` - Derive macro for generating typed scroll managers

## Version Compatibility

Minor versions align with ankurah (e.g., 0.7.x works with ankurah 0.7.x). Patch versions are independent.

## License

MIT OR Apache-2.0
