diff --git a/frontend/public/lock_scroll.js b/frontend/public/lock_scroll.js new file mode 100644 index 0000000..48c04eb --- /dev/null +++ b/frontend/public/lock_scroll.js @@ -0,0 +1,253 @@ +/** + * Scroll Lock Utility + * Handles locking and unlocking scroll for both window and all scrollable containers + * Similar to react-remove-scroll but in vanilla JavaScript + */ + +(() => { + // Prevent multiple initializations + if (window.ScrollLock) { + return; + } + + class ScrollLock { + constructor() { + this.locked = false; + this.scrollableElements = []; + this.scrollPositions = new Map(); + this.originalStyles = new Map(); + this.fixedElements = []; + } + + /** + * Find all scrollable elements in the DOM (optimized) + * Uses more targeted selectors instead of querying all elements + */ + findScrollableElements() { + const scrollables = []; + + // More targeted query - only look for elements with overflow properties + const candidates = document.querySelectorAll( + '[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div', + ); + + // Batch all style reads first to minimize reflows + const elementsToCheck = []; + for (const el of candidates) { + // Skip the element itself or if it's inside these containers + const dataName = el.getAttribute("data-name"); + const isExcludedElement = + dataName === "ScrollArea" || + dataName === "CommandList" || + dataName === "SelectContent" || + dataName === "MultiSelectContent" || + dataName === "DropdownMenuContent" || + dataName === "ContextMenuContent"; + + if ( + el !== document.body && + el !== document.documentElement && + !isExcludedElement && + !el.closest('[data-name="ScrollArea"]') && + !el.closest('[data-name="CommandList"]') && + !el.closest('[data-name="SelectContent"]') && + !el.closest('[data-name="MultiSelectContent"]') && + !el.closest('[data-name="DropdownMenuContent"]') && + !el.closest('[data-name="ContextMenuContent"]') + ) { + elementsToCheck.push(el); + } + } + + // Now batch read all computed styles and dimensions + elementsToCheck.forEach((el) => { + const style = window.getComputedStyle(el); + const hasOverflow = + style.overflow === "auto" || + style.overflow === "scroll" || + style.overflowY === "auto" || + style.overflowY === "scroll"; + + // Only check scrollHeight if overflow is set + if (hasOverflow && el.scrollHeight > el.clientHeight) { + scrollables.push(el); + } + }); + + return scrollables; + } + + /** + * Lock scrolling on all scrollable elements (optimized) + * Batches all DOM reads before DOM writes to prevent forced reflows + */ + lock() { + if (this.locked) return; + + this.locked = true; + + // Find all scrollable elements + this.scrollableElements = this.findScrollableElements(); + + // ===== BATCH 1: READ PHASE - Read all layout properties first ===== + const windowScrollY = window.scrollY; + const scrollbarWidth = window.innerWidth - document.body.clientWidth; + + // Store window scroll position + this.scrollPositions.set("window", windowScrollY); + + // Store original body styles + this.originalStyles.set("body", { + position: document.body.style.position, + top: document.body.style.top, + width: document.body.style.width, + overflow: document.body.style.overflow, + paddingRight: document.body.style.paddingRight, + }); + + // Read all fixed-position elements and their padding (only if we have scrollbar) + if (scrollbarWidth > 0) { + // Use more targeted query for fixed elements + const fixedCandidates = document.querySelectorAll( + '[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]', + ); + + this.fixedElements = Array.from(fixedCandidates).filter((el) => { + const style = window.getComputedStyle(el); + return ( + style.position === "fixed" && + !el.closest('[data-name="DropdownMenuContent"]') && + !el.closest('[data-name="MultiSelectContent"]') && + !el.closest('[data-name="ContextMenuContent"]') + ); + }); + + // Batch read all padding values + this.fixedElements.forEach((el) => { + const computedStyle = window.getComputedStyle(el); + const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0; + + this.originalStyles.set(el, { + paddingRight: el.style.paddingRight, + computedPadding: currentPadding, + }); + }); + } + + // Read scrollable elements info + const scrollableInfo = this.scrollableElements.map((el) => { + const scrollTop = el.scrollTop; + const elementScrollbarWidth = el.offsetWidth - el.clientWidth; + const computedStyle = window.getComputedStyle(el); + const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0; + + this.scrollPositions.set(el, scrollTop); + this.originalStyles.set(el, { + overflow: el.style.overflow, + overflowY: el.style.overflowY, + paddingRight: el.style.paddingRight, + }); + + return { el, elementScrollbarWidth, currentPadding }; + }); + + // ===== BATCH 2: WRITE PHASE - Apply all styles at once ===== + + // Apply body lock + document.body.style.position = "fixed"; + document.body.style.top = `-${windowScrollY}px`; + document.body.style.width = "100%"; + document.body.style.overflow = "hidden"; + + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + + // Apply padding compensation to fixed elements + this.fixedElements.forEach((el) => { + const stored = this.originalStyles.get(el); + if (stored) { + el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`; + } + }); + } + + // Lock all scrollable containers + scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => { + el.style.overflow = "hidden"; + + if (elementScrollbarWidth > 0) { + el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`; + } + }); + } + + /** + * Unlock scrolling on all elements (optimized) + * @param {number} delay - Delay in milliseconds before unlocking (for animations) + */ + unlock(delay = 0) { + if (!this.locked) return; + + const performUnlock = () => { + // Restore body scroll + const bodyStyles = this.originalStyles.get("body"); + if (bodyStyles) { + document.body.style.position = bodyStyles.position; + document.body.style.top = bodyStyles.top; + document.body.style.width = bodyStyles.width; + document.body.style.overflow = bodyStyles.overflow; + document.body.style.paddingRight = bodyStyles.paddingRight; + } + + // Restore window scroll position + const windowScrollY = this.scrollPositions.get("window") || 0; + window.scrollTo(0, windowScrollY); + + // Restore all scrollable containers + this.scrollableElements.forEach((el) => { + const originalStyles = this.originalStyles.get(el); + if (originalStyles) { + el.style.overflow = originalStyles.overflow; + el.style.overflowY = originalStyles.overflowY; + el.style.paddingRight = originalStyles.paddingRight; + } + + // Restore scroll position + const scrollPosition = this.scrollPositions.get(el) || 0; + el.scrollTop = scrollPosition; + }); + + // Restore fixed-position elements padding + this.fixedElements.forEach((el) => { + const styles = this.originalStyles.get(el); + if (styles && styles.paddingRight !== undefined) { + el.style.paddingRight = styles.paddingRight; + } + }); + + // Clear storage + this.scrollableElements = []; + this.fixedElements = []; + this.scrollPositions.clear(); + this.originalStyles.clear(); + this.locked = false; + }; + + if (delay > 0) { + setTimeout(performUnlock, delay); + } else { + performUnlock(); + } + } + + /** + * Check if scrolling is currently locked + */ + isLocked() { + return this.locked; + } + } + + // Export as singleton + window.ScrollLock = new ScrollLock(); +})(); diff --git a/frontend/src/components/torrent/table.rs b/frontend/src/components/torrent/table.rs index 56f68a2..0f9c451 100644 --- a/frontend/src/components/torrent/table.rs +++ b/frontend/src/components/torrent/table.rs @@ -204,7 +204,7 @@ pub fn TorrentTable() -> impl IntoView { - } + }.into_any() } #[component] @@ -269,7 +269,7 @@ fn TorrentRow( {format_date(t.added_date)} - } + }.into_any() } } diff --git a/frontend/src/components/ui/context_menu.rs b/frontend/src/components/ui/context_menu.rs index 110c483..ab0dfed 100644 --- a/frontend/src/components/ui/context_menu.rs +++ b/frontend/src/components/ui/context_menu.rs @@ -145,7 +145,7 @@ pub fn ContextMenuHoldAction( {children()} - } + }.into_any() } #[derive(Clone)] @@ -237,7 +237,7 @@ pub fn ContextMenuContent( let target_id_for_script = ctx.target_id.clone(); view! { - +
- } + }.into_any() } #[component] diff --git a/frontend/src/components/ui/mod.rs b/frontend/src/components/ui/mod.rs index 4692a67..567ef02 100644 --- a/frontend/src/components/ui/mod.rs +++ b/frontend/src/components/ui/mod.rs @@ -5,3 +5,4 @@ pub mod toast; pub mod context_menu; pub mod theme_toggle; pub mod svg_icon; +pub mod table; \ No newline at end of file diff --git a/frontend/src/components/ui/table.rs b/frontend/src/components/ui/table.rs index b5dcd68..9a874ee 100644 --- a/frontend/src/components/ui/table.rs +++ b/frontend/src/components/ui/table.rs @@ -1,19 +1,44 @@ use leptos::prelude::*; -use leptos_ui::clx; +use tw_merge::tw_merge; -mod components { - use super::*; - clx! {TableWrapper, div, "overflow-hidden rounded-md border"} - clx! {Table, table, "w-full max-w-7xl text-sm caption-bottom"} - clx! {TableCaption, caption, "mt-4 text-sm text-muted-foreground"} - clx! {TableHeader, thead, "[&_tr]:border-b"} - clx! {TableRow, tr, "border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50"} - clx! {TableHead, th, "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"} - clx! {TableBody, tbody, "[&_tr:last-child]:border-0"} - clx! {TableCell, td, "p-4 align-middle [&:has([role=checkbox])]:pr-0 &:has([role=checkbox])]:pl-3"} - clx! {TableFooter, tfoot, "font-medium border border-t bg-muted/50 [&>tr]:last:border-b-0"} - clx! {CardContent, div, "pt-4"} - clx! {CardFooter, div, "mt-4", "flex items-center justify-end"} +#[component] +pub fn TableWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("overflow-hidden rounded-md border", class); + view! {
{children()}
} } -pub use components::*; \ No newline at end of file +#[component] +pub fn Table(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("w-full text-sm caption-bottom", class); + view! { {children()}
} +} + +#[component] +pub fn TableHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("[&_tr]:border-b", class); + view! { {children()} } +} + +#[component] +pub fn TableRow(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", class); + view! { {children()} } +} + +#[component] +pub fn TableHead(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("h-10 px-2 text-left align-middle font-medium text-muted-foreground", class); + view! { {children()} } +} + +#[component] +pub fn TableBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("[&_tr:last-child]:border-0", class); + view! { {children()} } +} + +#[component] +pub fn TableCell(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("p-2 align-middle", class); + view! { {children()} } +}