浏览代码

Add auto scroll (#4790)

* add auto_scroll

* add auto_scroll

* add auto_scroll to global

* use random id for maximum safety
Khaleel Al-Adhami 3 月之前
父节点
当前提交
3129ddab47

+ 1 - 0
reflex/__init__.py

@@ -248,6 +248,7 @@ COMPONENTS_CORE_MAPPING: dict = {
         "selected_files",
         "selected_files",
         "upload",
         "upload",
     ],
     ],
+    "components.core.auto_scroll": ["auto_scroll"],
 }
 }
 
 
 COMPONENTS_BASE_MAPPING: dict = {
 COMPONENTS_BASE_MAPPING: dict = {

+ 1 - 0
reflex/__init__.pyi

@@ -34,6 +34,7 @@ from .components.component import Component as Component
 from .components.component import ComponentNamespace as ComponentNamespace
 from .components.component import ComponentNamespace as ComponentNamespace
 from .components.component import NoSSRComponent as NoSSRComponent
 from .components.component import NoSSRComponent as NoSSRComponent
 from .components.component import memo as memo
 from .components.component import memo as memo
+from .components.core.auto_scroll import auto_scroll as auto_scroll
 from .components.core.banner import connection_banner as connection_banner
 from .components.core.banner import connection_banner as connection_banner
 from .components.core.banner import connection_modal as connection_modal
 from .components.core.banner import connection_modal as connection_modal
 from .components.core.breakpoints import breakpoints as breakpoints
 from .components.core.breakpoints import breakpoints as breakpoints

+ 1 - 0
reflex/components/core/__init__.py

@@ -48,6 +48,7 @@ _SUBMOD_ATTRS: dict[str, list[str]] = {
         "get_upload_url",
         "get_upload_url",
         "selected_files",
         "selected_files",
     ],
     ],
+    "auto_scroll": ["auto_scroll"],
 }
 }
 
 
 __getattr__, __dir__, __all__ = lazy_loader.attach(
 __getattr__, __dir__, __all__ = lazy_loader.attach(

+ 1 - 0
reflex/components/core/__init__.pyi

@@ -4,6 +4,7 @@
 # ------------------------------------------------------
 # ------------------------------------------------------
 
 
 from . import layout as layout
 from . import layout as layout
+from .auto_scroll import auto_scroll as auto_scroll
 from .banner import ConnectionBanner as ConnectionBanner
 from .banner import ConnectionBanner as ConnectionBanner
 from .banner import ConnectionModal as ConnectionModal
 from .banner import ConnectionModal as ConnectionModal
 from .banner import ConnectionPulser as ConnectionPulser
 from .banner import ConnectionPulser as ConnectionPulser

+ 111 - 0
reflex/components/core/auto_scroll.py

@@ -0,0 +1,111 @@
+"""A component that automatically scrolls to the bottom when new content is added."""
+
+from __future__ import annotations
+
+from reflex.components.el.elements.typography import Div
+from reflex.constants.compiler import MemoizationDisposition, MemoizationMode
+from reflex.utils.imports import ImportDict
+from reflex.vars.base import Var, get_unique_variable_name
+
+
+class AutoScroll(Div):
+    """A div that automatically scrolls to the bottom when new content is added."""
+
+    _memoization_mode = MemoizationMode(disposition=MemoizationDisposition.ALWAYS)
+
+    @classmethod
+    def create(cls, *children, **props):
+        """Create an AutoScroll component.
+
+        Args:
+            *children: The children of the component.
+            **props: The props of the component.
+
+        Returns:
+            An AutoScroll component.
+        """
+        props.setdefault("overflow", "auto")
+        props.setdefault("id", get_unique_variable_name())
+        return super().create(*children, **props)
+
+    def add_imports(self) -> ImportDict | list[ImportDict]:
+        """Add imports required for the component.
+
+        Returns:
+            The imports required for the component.
+        """
+        return {"react": ["useEffect", "useRef"]}
+
+    def add_hooks(self) -> list[str | Var]:
+        """Add hooks required for the component.
+
+        Returns:
+            The hooks required for the component.
+        """
+        ref_name = self.get_ref()
+        return [
+            "const containerRef = useRef(null);",
+            "const wasNearBottom = useRef(false);",
+            "const hadScrollbar = useRef(false);",
+            f"""
+const checkIfNearBottom = () => {{
+    if (!{ref_name}.current) return;
+
+    const container = {ref_name}.current;
+    const nearBottomThreshold = 50; // pixels from bottom to trigger auto-scroll
+
+    const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
+
+    wasNearBottom.current = distanceFromBottom <= nearBottomThreshold;
+
+    // Track if container had a scrollbar
+    hadScrollbar.current = container.scrollHeight > container.clientHeight;
+}};
+""",
+            f"""
+const scrollToBottomIfNeeded = () => {{
+    if (!{ref_name}.current) return;
+
+    const container = {ref_name}.current;
+    const hasScrollbarNow = container.scrollHeight > container.clientHeight;
+
+    // Scroll if:
+    // 1. User was near bottom, OR
+    // 2. Container didn't have scrollbar before but does now
+    if (wasNearBottom.current || (!hadScrollbar.current && hasScrollbarNow)) {{
+      container.scrollTop = container.scrollHeight;
+    }}
+
+    // Update scrollbar state for next check
+    hadScrollbar.current = hasScrollbarNow;
+}};
+""",
+            f"""
+useEffect(() => {{
+    const container = {ref_name}.current;
+    if (!container) return;
+
+    // Create ResizeObserver to detect height changes
+    const resizeObserver = new ResizeObserver(() => {{
+        scrollToBottomIfNeeded();
+    }});
+
+    // Track scroll position before height changes
+    container.addEventListener('scroll', checkIfNearBottom);
+
+    // Initial check
+    checkIfNearBottom();
+
+    // Observe container for size changes
+    resizeObserver.observe(container);
+
+    return () => {{
+        container.removeEventListener('scroll', checkIfNearBottom);
+        resizeObserver.disconnect();
+    }};
+}});
+""",
+        ]
+
+
+auto_scroll = AutoScroll.create

+ 103 - 0
reflex/components/core/auto_scroll.pyi

@@ -0,0 +1,103 @@
+"""Stub file for reflex/components/core/auto_scroll.py"""
+
+# ------------------- DO NOT EDIT ----------------------
+# This file was generated by `reflex/utils/pyi_generator.py`!
+# ------------------------------------------------------
+from typing import Any, Dict, Optional, Union, overload
+
+from reflex.components.el.elements.typography import Div
+from reflex.event import EventType
+from reflex.style import Style
+from reflex.utils.imports import ImportDict
+from reflex.vars.base import Var
+
+class AutoScroll(Div):
+    @overload
+    @classmethod
+    def create(  # type: ignore
+        cls,
+        *children,
+        access_key: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        auto_capitalize: Optional[
+            Union[Var[Union[bool, int, str]], bool, int, str]
+        ] = None,
+        content_editable: Optional[
+            Union[Var[Union[bool, int, str]], bool, int, str]
+        ] = None,
+        context_menu: Optional[
+            Union[Var[Union[bool, int, str]], bool, int, str]
+        ] = None,
+        dir: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        draggable: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        enter_key_hint: Optional[
+            Union[Var[Union[bool, int, str]], bool, int, str]
+        ] = None,
+        hidden: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        input_mode: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        item_prop: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        lang: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        role: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        slot: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        spell_check: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        tab_index: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        title: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None,
+        style: Optional[Style] = None,
+        key: Optional[Any] = None,
+        id: Optional[Any] = None,
+        class_name: Optional[Any] = None,
+        autofocus: Optional[bool] = None,
+        custom_attrs: Optional[Dict[str, Union[Var, Any]]] = None,
+        on_blur: Optional[EventType[()]] = None,
+        on_click: Optional[EventType[()]] = None,
+        on_context_menu: Optional[EventType[()]] = None,
+        on_double_click: Optional[EventType[()]] = None,
+        on_focus: Optional[EventType[()]] = None,
+        on_mount: Optional[EventType[()]] = None,
+        on_mouse_down: Optional[EventType[()]] = None,
+        on_mouse_enter: Optional[EventType[()]] = None,
+        on_mouse_leave: Optional[EventType[()]] = None,
+        on_mouse_move: Optional[EventType[()]] = None,
+        on_mouse_out: Optional[EventType[()]] = None,
+        on_mouse_over: Optional[EventType[()]] = None,
+        on_mouse_up: Optional[EventType[()]] = None,
+        on_scroll: Optional[EventType[()]] = None,
+        on_unmount: Optional[EventType[()]] = None,
+        **props,
+    ) -> "AutoScroll":
+        """Create an AutoScroll component.
+
+        Args:
+            *children: The children of the component.
+            access_key: Provides a hint for generating a keyboard shortcut for the current element.
+            auto_capitalize: Controls whether and how text input is automatically capitalized as it is entered/edited by the user.
+            content_editable: Indicates whether the element's content is editable.
+            context_menu: Defines the ID of a <menu> element which will serve as the element's context menu.
+            dir: Defines the text direction. Allowed values are ltr (Left-To-Right) or rtl (Right-To-Left)
+            draggable: Defines whether the element can be dragged.
+            enter_key_hint: Hints what media types the media element is able to play.
+            hidden: Defines whether the element is hidden.
+            input_mode: Defines the type of the element.
+            item_prop: Defines the name of the element for metadata purposes.
+            lang: Defines the language used in the element.
+            role: Defines the role of the element.
+            slot: Assigns a slot in a shadow DOM shadow tree to an element.
+            spell_check: Defines whether the element may be checked for spelling errors.
+            tab_index: Defines the position of the current element in the tabbing order.
+            title: Defines a tooltip for the element.
+            style: The style of the component.
+            key: A unique key for the component.
+            id: The id for the component.
+            class_name: The class name for the component.
+            autofocus: Whether the component should take the focus once the page is loaded
+            custom_attrs: custom attribute
+            **props: The props of the component.
+
+        Returns:
+            An AutoScroll component.
+        """
+        ...
+
+    def add_imports(self) -> ImportDict | list[ImportDict]: ...
+    def add_hooks(self) -> list[str | Var]: ...
+
+auto_scroll = AutoScroll.create