mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	fix(web): scrollbar (#3536)
This commit is contained in:
		| @@ -8,10 +8,7 @@ | ||||
|   import AssetViewer from '../asset-viewer/asset-viewer.svelte'; | ||||
|   import IntersectionObserver from '../asset-viewer/intersection-observer.svelte'; | ||||
|   import Portal from '../shared-components/portal/portal.svelte'; | ||||
|   import Scrollbar, { | ||||
|     OnScrollbarClickDetail, | ||||
|     OnScrollbarDragDetail, | ||||
|   } from '../shared-components/scrollbar/scrollbar.svelte'; | ||||
|   import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte'; | ||||
|   import AssetDateGroup from './asset-date-group.svelte'; | ||||
|   import MemoryLane from './memory-lane.svelte'; | ||||
|  | ||||
| @@ -30,13 +27,13 @@ | ||||
|   export let assetInteractionStore: AssetInteractionStore; | ||||
|  | ||||
|   const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore; | ||||
|  | ||||
|   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore; | ||||
|  | ||||
|   const viewport: Viewport = { width: 0, height: 0 }; | ||||
|   let assetGridElement: HTMLElement; | ||||
|   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore; | ||||
|   let element: HTMLElement; | ||||
|   let showShortcuts = false; | ||||
|  | ||||
|   $: timelineY = element?.scrollTop || 0; | ||||
|  | ||||
|   const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); | ||||
|  | ||||
|   onMount(async () => { | ||||
| @@ -84,7 +81,7 @@ | ||||
|   } | ||||
|  | ||||
|   function handleScrollTimeline(event: CustomEvent) { | ||||
|     assetGridElement.scrollBy(0, event.detail.heightDelta); | ||||
|     element.scrollBy(0, event.detail.heightDelta); | ||||
|   } | ||||
|  | ||||
|   const navigateToPreviousAsset = async () => { | ||||
| @@ -101,26 +98,18 @@ | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   let lastScrollPosition = 0; | ||||
|   let animationTick = false; | ||||
|  | ||||
|   const handleTimelineScroll = () => { | ||||
|     if (!animationTick) { | ||||
|       window.requestAnimationFrame(() => { | ||||
|         lastScrollPosition = assetGridElement?.scrollTop; | ||||
|         animationTick = false; | ||||
|       }); | ||||
|     if (animationTick) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     animationTick = true; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleScrollbarClick = (e: OnScrollbarClickDetail) => { | ||||
|     assetGridElement.scrollTop = e.scrollTo; | ||||
|   }; | ||||
|  | ||||
|   const handleScrollbarDrag = (e: OnScrollbarDragDetail) => { | ||||
|     assetGridElement.scrollTop = e.scrollTo; | ||||
|     window.requestAnimationFrame(() => { | ||||
|       timelineY = element?.scrollTop || 0; | ||||
|       animationTick = false; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleArchiveSuccess = (e: CustomEvent) => { | ||||
| @@ -278,26 +267,23 @@ | ||||
|   <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} /> | ||||
| {/if} | ||||
|  | ||||
| {#if $assetStore.timelineHeight > viewport.height} | ||||
| <Scrollbar | ||||
|   {assetStore} | ||||
|     scrollbarHeight={viewport.height} | ||||
|     scrollTop={lastScrollPosition} | ||||
|     on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)} | ||||
|     on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)} | ||||
|   height={viewport.height} | ||||
|   {timelineY} | ||||
|   on:scrollTimeline={({ detail }) => (element.scrollTop = detail)} | ||||
| /> | ||||
| {/if} | ||||
|  | ||||
| <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar --> | ||||
| <section | ||||
|   id="asset-grid" | ||||
|   class="scrollbar-hidden mb-4 ml-4 mr-[60px] overflow-y-auto" | ||||
|   class="scrollbar-hidden ml-4 mr-[60px] overflow-y-auto pb-4" | ||||
|   bind:clientHeight={viewport.height} | ||||
|   bind:clientWidth={viewport.width} | ||||
|   bind:this={assetGridElement} | ||||
|   bind:this={element} | ||||
|   on:scroll={handleTimelineScroll} | ||||
| > | ||||
|   {#if assetGridElement} | ||||
|   {#if element} | ||||
|     {#if showMemoryLane} | ||||
|       <MemoryLane /> | ||||
|     {/if} | ||||
| @@ -309,7 +295,7 @@ | ||||
|           let:intersecting | ||||
|           top={750} | ||||
|           bottom={750} | ||||
|           root={assetGridElement} | ||||
|           root={element} | ||||
|         > | ||||
|           <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}> | ||||
|             {#if intersecting} | ||||
|   | ||||
| @@ -1,119 +1,85 @@ | ||||
| <script lang="ts" context="module"> | ||||
|   type OnScrollbarClick = { | ||||
|     onscrollbarclick: OnScrollbarClickDetail; | ||||
|   }; | ||||
|  | ||||
|   export type OnScrollbarClickDetail = { | ||||
|     scrollTo: number; | ||||
|   }; | ||||
|  | ||||
|   type OnScrollbarDrag = { | ||||
|     onscrollbardrag: OnScrollbarDragDetail; | ||||
|   }; | ||||
|  | ||||
|   export type OnScrollbarDragDetail = { | ||||
|     scrollTo: number; | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
|   import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store'; | ||||
|  | ||||
|   import { createEventDispatcher } from 'svelte'; | ||||
|   import { SegmentScrollbarLayout } from './segment-scrollbar-layout'; | ||||
|   import type { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { createEventDispatcher } from 'svelte'; | ||||
|  | ||||
|   export let scrollTop = 0; | ||||
|   export let scrollbarHeight = 0; | ||||
|   export let timelineY = 0; | ||||
|   export let height = 0; | ||||
|   export let assetStore: AssetStore; | ||||
|  | ||||
|   $: timelineHeight = $assetStore.timelineHeight; | ||||
|   $: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight; | ||||
|  | ||||
|   let segmentScrollbarLayout: SegmentScrollbarLayout[] = []; | ||||
|   let isHover = false; | ||||
|   let isDragging = false; | ||||
|   let hoveredDate: Date; | ||||
|   let currentMouseYLocation = 0; | ||||
|   let scrollbarPosition = 0; | ||||
|   let animationTick = false; | ||||
|   let isAnimating = false; | ||||
|   let hoverLabel = ''; | ||||
|   let clientY = 0; | ||||
|   let windowHeight = 0; | ||||
|  | ||||
|   const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore; | ||||
|   $: offset = $isAlbumAssetSelectionOpen ? 100 : 76; | ||||
|   const dispatchClick = createEventDispatcher<OnScrollbarClick>(); | ||||
|   const dispatchDrag = createEventDispatcher<OnScrollbarDrag>(); | ||||
|   $: { | ||||
|     scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight; | ||||
|   const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height; | ||||
|   const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height); | ||||
|  | ||||
|   const HOVER_DATE_HEIGHT = 30; | ||||
|  | ||||
|   $: hoverY = height - windowHeight + clientY; | ||||
|   $: scrollY = toScrollY(timelineY); | ||||
|   $: segments = $assetStore.buckets.map((bucket) => ({ | ||||
|     count: bucket.assets.length, | ||||
|     height: toScrollY(bucket.bucketHeight), | ||||
|     timeGroup: bucket.bucketDate, | ||||
|   })); | ||||
|  | ||||
|   const dispatch = createEventDispatcher<{ scrollTimeline: number }>(); | ||||
|   const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY)); | ||||
|  | ||||
|   const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => { | ||||
|     const wasDragging = isDragging; | ||||
|  | ||||
|     isDragging = event.isDragging ?? isDragging; | ||||
|     clientY = event.clientY; | ||||
|  | ||||
|     if (wasDragging === false && isDragging) { | ||||
|       scrollTimeline(); | ||||
|     } | ||||
|  | ||||
|   $: { | ||||
|     let result: SegmentScrollbarLayout[] = []; | ||||
|     for (const bucket of $assetStore.buckets) { | ||||
|       let segmentLayout = new SegmentScrollbarLayout(); | ||||
|       segmentLayout.count = bucket.assets.length; | ||||
|       segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight; | ||||
|       segmentLayout.timeGroup = bucket.bucketDate; | ||||
|       result.push(segmentLayout); | ||||
|     } | ||||
|     segmentScrollbarLayout = result; | ||||
|     if (!isDragging || isAnimating) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|   const handleMouseMove = (e: MouseEvent, currentDate: Date) => { | ||||
|     currentMouseYLocation = e.clientY - offset - 30; | ||||
|     isAnimating = true; | ||||
|  | ||||
|     hoveredDate = new Date(currentDate.toISOString().slice(0, -1)); | ||||
|   }; | ||||
|  | ||||
|   const handleMouseDown = (e: MouseEvent) => { | ||||
|     isDragging = true; | ||||
|     scrollbarPosition = e.clientY - offset; | ||||
|   }; | ||||
|  | ||||
|   const handleMouseUp = (e: MouseEvent) => { | ||||
|     isDragging = false; | ||||
|     scrollbarPosition = e.clientY - offset; | ||||
|     dispatchClick('onscrollbarclick', { scrollTo: timelineScrolltop }); | ||||
|   }; | ||||
|  | ||||
|   const handleMouseDrag = (e: MouseEvent) => { | ||||
|     if (isDragging) { | ||||
|       if (!animationTick) { | ||||
|     window.requestAnimationFrame(() => { | ||||
|           const dy = e.clientY - scrollbarPosition - offset; | ||||
|           scrollbarPosition += dy; | ||||
|           dispatchDrag('onscrollbardrag', { scrollTo: timelineScrolltop }); | ||||
|           animationTick = false; | ||||
|       scrollTimeline(); | ||||
|       isAnimating = false; | ||||
|     }); | ||||
|  | ||||
|         animationTick = true; | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <svelte:window bind:innerHeight={windowHeight} /> | ||||
|  | ||||
| <!-- svelte-ignore a11y-no-static-element-interactions --> | ||||
|  | ||||
| {#if $assetStore.timelineHeight > height} | ||||
|   <div | ||||
|     id="immich-scrubbable-scrollbar" | ||||
|     class="fixed right-0 z-[100] select-none bg-immich-bg hover:cursor-row-resize" | ||||
|     style:width={isDragging ? '100vw' : '60px'} | ||||
|     style:height={height + 'px'} | ||||
|     style:background-color={isDragging ? 'transparent' : 'transparent'} | ||||
|     draggable="false" | ||||
|     on:mouseenter={() => (isHover = true)} | ||||
|     on:mouseleave={() => { | ||||
|       isHover = false; | ||||
|       isDragging = false; | ||||
|     }} | ||||
|   on:mouseup={handleMouseUp} | ||||
|   on:mousemove={handleMouseDrag} | ||||
|   on:mousedown={handleMouseDown} | ||||
|   style:height={scrollbarHeight + 'px'} | ||||
|     on:mouseenter={({ clientY, buttons }) => handleMouseEvent({ clientY, isDragging: !!buttons })} | ||||
|     on:mousemove={({ clientY }) => handleMouseEvent({ clientY })} | ||||
|     on:mousedown={({ clientY }) => handleMouseEvent({ clientY, isDragging: true })} | ||||
|     on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} | ||||
|   > | ||||
|     {#if isHover} | ||||
|       <div | ||||
|         class="pointer-events-none absolute right-0 z-[100] w-[100px] rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 pl-1 pr-6 text-sm font-medium shadow-lg dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg" | ||||
|       style:top={currentMouseYLocation + 'px'} | ||||
|         style:top="{Math.max(hoverY - HOVER_DATE_HEIGHT, 0)}px" | ||||
|       > | ||||
|       {hoveredDate?.toLocaleString('default', { month: 'short' })} | ||||
|       {hoveredDate?.getFullYear()} | ||||
|         {hoverLabel} | ||||
|       </div> | ||||
|     {/if} | ||||
|  | ||||
| @@ -121,27 +87,30 @@ | ||||
|     {#if !isDragging} | ||||
|       <div | ||||
|         class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" | ||||
|       style:top={scrollbarPosition + 'px'} | ||||
|         style:top="{scrollY}px" | ||||
|       /> | ||||
|     {/if} | ||||
|     <!-- Time Segment --> | ||||
|   {#each segmentScrollbarLayout as segment, index (segment.timeGroup)} | ||||
|     {@const groupDate = new Date(segment.timeGroup)} | ||||
|     {#each segments as segment, index (segment.timeGroup)} | ||||
|       {@const date = new Date(segment.timeGroup)} | ||||
|       {@const year = date.getFullYear()} | ||||
|       {@const label = `${date.toLocaleString('default', { month: 'short' })} ${year}`} | ||||
|  | ||||
|       <!-- svelte-ignore a11y-no-static-element-interactions --> | ||||
|       <div | ||||
|         id="time-segment" | ||||
|         class="relative" | ||||
|         style:height={segment.height + 'px'} | ||||
|         aria-label={segment.timeGroup + ' ' + segment.count} | ||||
|       on:mousemove={(e) => handleMouseMove(e, groupDate)} | ||||
|         on:mousemove={() => (hoverLabel = label)} | ||||
|       > | ||||
|       {#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()} | ||||
|         {#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year} | ||||
|           {#if segment.height > 8} | ||||
|             <div | ||||
|               aria-label={segment.timeGroup + ' ' + segment.count} | ||||
|               class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg" | ||||
|             > | ||||
|             {groupDate.getFullYear()} | ||||
|               {year} | ||||
|             </div> | ||||
|           {/if} | ||||
|         {:else if segment.height > 5} | ||||
| @@ -153,6 +122,7 @@ | ||||
|       </div> | ||||
|     {/each} | ||||
|   </div> | ||||
| {/if} | ||||
|  | ||||
| <style> | ||||
|   #immich-scrubbable-scrollbar, | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| export class SegmentScrollbarLayout { | ||||
|   height!: number; | ||||
|   timeGroup!: string; | ||||
|   count!: number; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user