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 AssetViewer from '../asset-viewer/asset-viewer.svelte'; | ||||||
|   import IntersectionObserver from '../asset-viewer/intersection-observer.svelte'; |   import IntersectionObserver from '../asset-viewer/intersection-observer.svelte'; | ||||||
|   import Portal from '../shared-components/portal/portal.svelte'; |   import Portal from '../shared-components/portal/portal.svelte'; | ||||||
|   import Scrollbar, { |   import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte'; | ||||||
|     OnScrollbarClickDetail, |  | ||||||
|     OnScrollbarDragDetail, |  | ||||||
|   } from '../shared-components/scrollbar/scrollbar.svelte'; |  | ||||||
|   import AssetDateGroup from './asset-date-group.svelte'; |   import AssetDateGroup from './asset-date-group.svelte'; | ||||||
|   import MemoryLane from './memory-lane.svelte'; |   import MemoryLane from './memory-lane.svelte'; | ||||||
|  |  | ||||||
| @@ -30,13 +27,13 @@ | |||||||
|   export let assetInteractionStore: AssetInteractionStore; |   export let assetInteractionStore: AssetInteractionStore; | ||||||
|  |  | ||||||
|   const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore; |   const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore; | ||||||
|  |  | ||||||
|   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore; |  | ||||||
|  |  | ||||||
|   const viewport: Viewport = { width: 0, height: 0 }; |   const viewport: Viewport = { width: 0, height: 0 }; | ||||||
|   let assetGridElement: HTMLElement; |   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore; | ||||||
|  |   let element: HTMLElement; | ||||||
|   let showShortcuts = false; |   let showShortcuts = false; | ||||||
|  |  | ||||||
|  |   $: timelineY = element?.scrollTop || 0; | ||||||
|  |  | ||||||
|   const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); |   const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); | ||||||
|  |  | ||||||
|   onMount(async () => { |   onMount(async () => { | ||||||
| @@ -84,7 +81,7 @@ | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   function handleScrollTimeline(event: CustomEvent) { |   function handleScrollTimeline(event: CustomEvent) { | ||||||
|     assetGridElement.scrollBy(0, event.detail.heightDelta); |     element.scrollBy(0, event.detail.heightDelta); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const navigateToPreviousAsset = async () => { |   const navigateToPreviousAsset = async () => { | ||||||
| @@ -101,26 +98,18 @@ | |||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   let lastScrollPosition = 0; |  | ||||||
|   let animationTick = false; |   let animationTick = false; | ||||||
|  |  | ||||||
|   const handleTimelineScroll = () => { |   const handleTimelineScroll = () => { | ||||||
|     if (!animationTick) { |     if (animationTick) { | ||||||
|       window.requestAnimationFrame(() => { |       return; | ||||||
|         lastScrollPosition = assetGridElement?.scrollTop; |  | ||||||
|         animationTick = false; |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       animationTick = true; |  | ||||||
|     } |     } | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   const handleScrollbarClick = (e: OnScrollbarClickDetail) => { |     animationTick = true; | ||||||
|     assetGridElement.scrollTop = e.scrollTo; |     window.requestAnimationFrame(() => { | ||||||
|   }; |       timelineY = element?.scrollTop || 0; | ||||||
|  |       animationTick = false; | ||||||
|   const handleScrollbarDrag = (e: OnScrollbarDragDetail) => { |     }); | ||||||
|     assetGridElement.scrollTop = e.scrollTo; |  | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleArchiveSuccess = (e: CustomEvent) => { |   const handleArchiveSuccess = (e: CustomEvent) => { | ||||||
| @@ -278,26 +267,23 @@ | |||||||
|   <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} /> |   <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} /> | ||||||
| {/if} | {/if} | ||||||
|  |  | ||||||
| {#if $assetStore.timelineHeight > viewport.height} | <Scrollbar | ||||||
|   <Scrollbar |   {assetStore} | ||||||
|     {assetStore} |   height={viewport.height} | ||||||
|     scrollbarHeight={viewport.height} |   {timelineY} | ||||||
|     scrollTop={lastScrollPosition} |   on:scrollTimeline={({ detail }) => (element.scrollTop = detail)} | ||||||
|     on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)} | /> | ||||||
|     on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)} |  | ||||||
|   /> |  | ||||||
| {/if} |  | ||||||
|  |  | ||||||
| <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar --> | <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar --> | ||||||
| <section | <section | ||||||
|   id="asset-grid" |   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:clientHeight={viewport.height} | ||||||
|   bind:clientWidth={viewport.width} |   bind:clientWidth={viewport.width} | ||||||
|   bind:this={assetGridElement} |   bind:this={element} | ||||||
|   on:scroll={handleTimelineScroll} |   on:scroll={handleTimelineScroll} | ||||||
| > | > | ||||||
|   {#if assetGridElement} |   {#if element} | ||||||
|     {#if showMemoryLane} |     {#if showMemoryLane} | ||||||
|       <MemoryLane /> |       <MemoryLane /> | ||||||
|     {/if} |     {/if} | ||||||
| @@ -309,7 +295,7 @@ | |||||||
|           let:intersecting |           let:intersecting | ||||||
|           top={750} |           top={750} | ||||||
|           bottom={750} |           bottom={750} | ||||||
|           root={assetGridElement} |           root={element} | ||||||
|         > |         > | ||||||
|           <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}> |           <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}> | ||||||
|             {#if intersecting} |             {#if intersecting} | ||||||
|   | |||||||
| @@ -1,158 +1,128 @@ | |||||||
| <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"> | <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 type { AssetStore } from '$lib/stores/assets.store'; | ||||||
|  |   import { createEventDispatcher } from 'svelte'; | ||||||
|  |  | ||||||
|   export let scrollTop = 0; |   export let timelineY = 0; | ||||||
|   export let scrollbarHeight = 0; |   export let height = 0; | ||||||
|   export let assetStore: AssetStore; |   export let assetStore: AssetStore; | ||||||
|  |  | ||||||
|   $: timelineHeight = $assetStore.timelineHeight; |  | ||||||
|   $: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight; |  | ||||||
|  |  | ||||||
|   let segmentScrollbarLayout: SegmentScrollbarLayout[] = []; |  | ||||||
|   let isHover = false; |   let isHover = false; | ||||||
|   let isDragging = false; |   let isDragging = false; | ||||||
|   let hoveredDate: Date; |   let isAnimating = false; | ||||||
|   let currentMouseYLocation = 0; |   let hoverLabel = ''; | ||||||
|   let scrollbarPosition = 0; |   let clientY = 0; | ||||||
|   let animationTick = false; |   let windowHeight = 0; | ||||||
|  |  | ||||||
|   const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore; |   const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height; | ||||||
|   $: offset = $isAlbumAssetSelectionOpen ? 100 : 76; |   const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height); | ||||||
|   const dispatchClick = createEventDispatcher<OnScrollbarClick>(); |  | ||||||
|   const dispatchDrag = createEventDispatcher<OnScrollbarDrag>(); |  | ||||||
|   $: { |  | ||||||
|     scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   $: { |   const HOVER_DATE_HEIGHT = 30; | ||||||
|     let result: SegmentScrollbarLayout[] = []; |  | ||||||
|     for (const bucket of $assetStore.buckets) { |   $: hoverY = height - windowHeight + clientY; | ||||||
|       let segmentLayout = new SegmentScrollbarLayout(); |   $: scrollY = toScrollY(timelineY); | ||||||
|       segmentLayout.count = bucket.assets.length; |   $: segments = $assetStore.buckets.map((bucket) => ({ | ||||||
|       segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight; |     count: bucket.assets.length, | ||||||
|       segmentLayout.timeGroup = bucket.bucketDate; |     height: toScrollY(bucket.bucketHeight), | ||||||
|       result.push(segmentLayout); |     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(); | ||||||
|     } |     } | ||||||
|     segmentScrollbarLayout = result; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const handleMouseMove = (e: MouseEvent, currentDate: Date) => { |     if (!isDragging || isAnimating) { | ||||||
|     currentMouseYLocation = e.clientY - offset - 30; |       return; | ||||||
|  |  | ||||||
|     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; |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         animationTick = true; |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     isAnimating = true; | ||||||
|  |  | ||||||
|  |     window.requestAnimationFrame(() => { | ||||||
|  |       scrollTimeline(); | ||||||
|  |       isAnimating = false; | ||||||
|  |     }); | ||||||
|   }; |   }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | <svelte:window bind:innerHeight={windowHeight} /> | ||||||
|  |  | ||||||
| <!-- svelte-ignore a11y-no-static-element-interactions --> | <!-- svelte-ignore a11y-no-static-element-interactions --> | ||||||
| <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:background-color={isDragging ? 'transparent' : 'transparent'} |  | ||||||
|   on:mouseenter={() => (isHover = true)} |  | ||||||
|   on:mouseleave={() => { |  | ||||||
|     isHover = false; |  | ||||||
|     isDragging = false; |  | ||||||
|   }} |  | ||||||
|   on:mouseup={handleMouseUp} |  | ||||||
|   on:mousemove={handleMouseDrag} |  | ||||||
|   on:mousedown={handleMouseDown} |  | ||||||
|   style:height={scrollbarHeight + 'px'} |  | ||||||
| > |  | ||||||
|   {#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'} |  | ||||||
|     > |  | ||||||
|       {hoveredDate?.toLocaleString('default', { month: 'short' })} |  | ||||||
|       {hoveredDate?.getFullYear()} |  | ||||||
|     </div> |  | ||||||
|   {/if} |  | ||||||
|  |  | ||||||
|   <!-- Scroll Position Indicator Line --> | {#if $assetStore.timelineHeight > height} | ||||||
|   {#if !isDragging} |   <div | ||||||
|     <div |     id="immich-scrubbable-scrollbar" | ||||||
|       class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" |     class="fixed right-0 z-[100] select-none bg-immich-bg hover:cursor-row-resize" | ||||||
|       style:top={scrollbarPosition + 'px'} |     style:width={isDragging ? '100vw' : '60px'} | ||||||
|     /> |     style:height={height + 'px'} | ||||||
|   {/if} |     style:background-color={isDragging ? 'transparent' : 'transparent'} | ||||||
|   <!-- Time Segment --> |     draggable="false" | ||||||
|   {#each segmentScrollbarLayout as segment, index (segment.timeGroup)} |     on:mouseenter={() => (isHover = true)} | ||||||
|     {@const groupDate = new Date(segment.timeGroup)} |     on:mouseleave={() => { | ||||||
|  |       isHover = false; | ||||||
|  |       isDragging = false; | ||||||
|  |     }} | ||||||
|  |     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="{Math.max(hoverY - HOVER_DATE_HEIGHT, 0)}px" | ||||||
|  |       > | ||||||
|  |         {hoverLabel} | ||||||
|  |       </div> | ||||||
|  |     {/if} | ||||||
|  |  | ||||||
|     <div |     <!-- Scroll Position Indicator Line --> | ||||||
|       id="time-segment" |     {#if !isDragging} | ||||||
|       class="relative" |       <div | ||||||
|       style:height={segment.height + 'px'} |         class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" | ||||||
|       aria-label={segment.timeGroup + ' ' + segment.count} |         style:top="{scrollY}px" | ||||||
|       on:mousemove={(e) => handleMouseMove(e, groupDate)} |       /> | ||||||
|     > |     {/if} | ||||||
|       {#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()} |     <!-- Time Segment --> | ||||||
|         {#if segment.height > 8} |     {#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={() => (hoverLabel = label)} | ||||||
|  |       > | ||||||
|  |         {#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" | ||||||
|  |             > | ||||||
|  |               {year} | ||||||
|  |             </div> | ||||||
|  |           {/if} | ||||||
|  |         {:else if segment.height > 5} | ||||||
|           <div |           <div | ||||||
|             aria-label={segment.timeGroup + ' ' + segment.count} |             aria-label={segment.timeGroup + ' ' + segment.count} | ||||||
|             class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg" |             class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300" | ||||||
|           > |           /> | ||||||
|             {groupDate.getFullYear()} |  | ||||||
|           </div> |  | ||||||
|         {/if} |         {/if} | ||||||
|       {:else if segment.height > 5} |       </div> | ||||||
|         <div |     {/each} | ||||||
|           aria-label={segment.timeGroup + ' ' + segment.count} |   </div> | ||||||
|           class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300" | {/if} | ||||||
|         /> |  | ||||||
|       {/if} |  | ||||||
|     </div> |  | ||||||
|   {/each} |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <style> | <style> | ||||||
|   #immich-scrubbable-scrollbar, |   #immich-scrubbable-scrollbar, | ||||||
|   | |||||||
| @@ -1,5 +0,0 @@ | |||||||
| export class SegmentScrollbarLayout { |  | ||||||
|   height!: number; |  | ||||||
|   timeGroup!: string; |  | ||||||
|   count!: number; |  | ||||||
| } |  | ||||||
		Reference in New Issue
	
	Block a user