diff --git a/src/modules/blocks/code/RenderCode.tsx b/src/modules/blocks/code/RenderCode.tsx index 1ad9f5f90..b8ebbe8f9 100644 --- a/src/modules/blocks/code/RenderCode.tsx +++ b/src/modules/blocks/code/RenderCode.tsx @@ -25,6 +25,7 @@ import { RenderCodeSyntax } from './code-renderers/RenderCodeSyntax'; import { heuristicIsBlockPureHTML } from '../danger-html/RenderDangerousHtml'; import { heuristicIsCodePlantUML, RenderCodePlantUML, usePlantUmlSvg } from './code-renderers/RenderCodePlantUML'; import { useOpenInWebEditors } from './code-buttons/useOpenInWebEditors'; +import { useStickyCodeOverlay } from './useStickyCodeOverlay'; // style for line-numbers import './RenderCode.css'; @@ -130,6 +131,9 @@ function RenderCodeImpl(props: RenderCodeBaseProps & { // external state const { isFullscreen, enterFullscreen, exitFullscreen } = useFullscreenElement(fullScreenElementRef); + const { overlayRef, overlayBoundaryRef } = useStickyCodeOverlay({ disabled: props.optimizeLightweight || isFullscreen }); + + // sticky overlay positioning const { uiComplexityMode, showLineNumbers, showSoftWrap, setShowLineNumbers, setShowSoftWrap } = useUIPreferencesStore(useShallow(state => ({ uiComplexityMode: state.complexityMode, showLineNumbers: state.renderCodeLineNumbers, @@ -245,6 +249,7 @@ function RenderCodeImpl(props: RenderCodeBaseProps & { return ( + {/* [row 1] */} diff --git a/src/modules/blocks/code/useStickyCodeOverlay.tsx b/src/modules/blocks/code/useStickyCodeOverlay.tsx new file mode 100644 index 000000000..c4245ed4c --- /dev/null +++ b/src/modules/blocks/code/useStickyCodeOverlay.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; + +interface UseStickyCodeOverlayOptions { + disabled?: boolean; + /** Custom data attribute to define scroll boundary (default: 'data-sticky-boundary') */ + boundarySelector?: string; +} + +/** + * Makes overlay elements stick to scroll container top during scroll. + * Performance-optimized: only runs JavaScript when hovering (when overlays are visible). + * + * ``` + * ScrollContainer [role="scrollable" or custom boundary selector] + * └── ... (other content) + * └── OverlayBoundary (overlayBoundaryRef - hover events + positioning bounds) + * └── OverlayElement (overlayRef - gets sticky positioning) + * ``` + * + * Key insights: + * - overlayBoundaryRef serves dual purpose: hover detection AND positioning bounds calculation + * - Scroll listeners only active during hover = zero JavaScript execution when not hovering + * - Fallback: if overlayBoundaryRef unused, defaults to overlay's parent for hover detection + * - Finds scroll container via closest() with role="scrollable" or custom boundarySelector + */ +export function useStickyCodeOverlay(options?: UseStickyCodeOverlayOptions) { + + // state passed to the caller + const overlayRef = React.useRef(null); + const overlayBoundaryRef = React.useRef(null); + + + React.useEffect(() => { + if (options?.disabled || !overlayRef.current) return; + + // Find the scrolling container using closest() - try custom boundary first, then role='scrollable' + const boundarySelector = options?.boundarySelector || '[data-sticky-boundary]'; + const scrollContainer = + overlayRef.current.closest(boundarySelector) || + overlayRef.current.closest('[role="scrollable"]'); + + if (!scrollContainer) return; // No scroll container found + + // -- Scrolling interception & element positioning while Active -- + + // Sticky positioning logic + const applyStickyPosition = () => { + if (!overlayRef.current) return; + + const codeContainer = overlayRef.current.parentElement; + if (!codeContainer) return; + + const containerRect = codeContainer.getBoundingClientRect(); + const scrollRect = scrollContainer.getBoundingClientRect(); + const stickyThreshold = scrollRect.top + 2; // 2px offset like chat avatars + + const shouldBeSticky = + containerRect.top < stickyThreshold && + containerRect.bottom > stickyThreshold + 44; // 44px minimum visibility + + const overlay = overlayRef.current; + if (shouldBeSticky) { + overlay.style.position = 'fixed'; + overlay.style.top = `${stickyThreshold}px`; + overlay.style.right = `${window.innerWidth - containerRect.right}px`; + overlay.style.zIndex = '10'; + } else if (overlay.style.position === 'fixed') { + resetToNormalPosition(); + } + }; + + const resetToNormalPosition = () => { + if (!overlayRef.current) return; + const overlay = overlayRef.current; + overlay.style.position = ''; + overlay.style.top = ''; + overlay.style.right = ''; + overlay.style.zIndex = ''; + }; + + const handleScroll = () => requestAnimationFrame(applyStickyPosition); + + + // -- Activation/deactivation logic - only when overlay is visible (on hover) -- + + const activateStickyBehavior = () => { + scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); + applyStickyPosition(); // Check initial position + }; + + const deactivateStickyBehavior = () => { + scrollContainer.removeEventListener('scroll', handleScroll); + resetToNormalPosition(); + }; + + const boundaryContainer = overlayBoundaryRef.current || overlayRef.current.parentElement; + if (boundaryContainer) { + boundaryContainer.addEventListener('mouseenter', activateStickyBehavior); + boundaryContainer.addEventListener('mouseleave', deactivateStickyBehavior); + } + + return () => { + if (boundaryContainer) { + boundaryContainer.removeEventListener('mouseenter', activateStickyBehavior); + boundaryContainer.removeEventListener('mouseleave', deactivateStickyBehavior); + } + // Ensure scroll listener is removed + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, [options?.disabled, options?.boundarySelector]); + + return { + overlayRef, + overlayBoundaryRef, + }; +} \ No newline at end of file