Browse Source

fix(web): scrollbar (#3536)

Jason Rasmussen 2 years ago
parent
commit
6da51deb83

+ 23 - 37
web/src/lib/components/photos-page/asset-grid.svelte

@@ -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;
-      });
-
-      animationTick = true;
+    if (animationTick) {
+      return;
     }
-  };
 
-  const handleScrollbarClick = (e: OnScrollbarClickDetail) => {
-    assetGridElement.scrollTop = e.scrollTo;
-  };
-
-  const handleScrollbarDrag = (e: OnScrollbarDragDetail) => {
-    assetGridElement.scrollTop = e.scrollTo;
+    animationTick = true;
+    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)}
-  />
-{/if}
+<Scrollbar
+  {assetStore}
+  height={viewport.height}
+  {timelineY}
+  on:scrollTimeline={({ detail }) => (element.scrollTop = detail)}
+/>
 
 <!-- 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}

+ 104 - 134
web/src/lib/components/shared-components/scrollbar/scrollbar.svelte

@@ -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">
-  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;
-
-  const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
-  $: offset = $isAlbumAssetSelectionOpen ? 100 : 76;
-  const dispatchClick = createEventDispatcher<OnScrollbarClick>();
-  const dispatchDrag = createEventDispatcher<OnScrollbarDrag>();
-  $: {
-    scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
-  }
+  let isAnimating = false;
+  let hoverLabel = '';
+  let clientY = 0;
+  let windowHeight = 0;
 
-  $: {
-    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;
-  }
+  const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height;
+  const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height);
 
-  const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
-    currentMouseYLocation = e.clientY - offset - 30;
+  const HOVER_DATE_HEIGHT = 30;
 
-    hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
-  };
+  $: hoverY = height - windowHeight + clientY;
+  $: scrollY = toScrollY(timelineY);
+  $: segments = $assetStore.buckets.map((bucket) => ({
+    count: bucket.assets.length,
+    height: toScrollY(bucket.bucketHeight),
+    timeGroup: bucket.bucketDate,
+  }));
 
-  const handleMouseDown = (e: MouseEvent) => {
-    isDragging = true;
-    scrollbarPosition = e.clientY - offset;
-  };
+  const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
+  const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
 
-  const handleMouseUp = (e: MouseEvent) => {
-    isDragging = false;
-    scrollbarPosition = e.clientY - offset;
-    dispatchClick('onscrollbarclick', { scrollTo: timelineScrolltop });
-  };
+  const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
+    const wasDragging = isDragging;
+
+    isDragging = event.isDragging ?? isDragging;
+    clientY = event.clientY;
 
-  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;
-      }
+    if (wasDragging === false && isDragging) {
+      scrollTimeline();
     }
+
+    if (!isDragging || isAnimating) {
+      return;
+    }
+
+    isAnimating = true;
+
+    window.requestAnimationFrame(() => {
+      scrollTimeline();
+      isAnimating = false;
+    });
   };
 </script>
 
+<svelte:window bind:innerHeight={windowHeight} />
+
 <!-- 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 !isDragging}
-    <div
-      class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
-      style:top={scrollbarPosition + 'px'}
-    />
-  {/if}
-  <!-- Time Segment -->
-  {#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
-    {@const groupDate = new Date(segment.timeGroup)}
-
-    <div
-      id="time-segment"
-      class="relative"
-      style:height={segment.height + 'px'}
-      aria-label={segment.timeGroup + ' ' + segment.count}
-      on:mousemove={(e) => handleMouseMove(e, groupDate)}
-    >
-      {#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
-        {#if segment.height > 8}
+
+{#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: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}
+
+    <!-- Scroll Position Indicator Line -->
+    {#if !isDragging}
+      <div
+        class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
+        style:top="{scrollY}px"
+      />
+    {/if}
+    <!-- Time Segment -->
+    {#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
             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()}
-          </div>
+            class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300"
+          />
         {/if}
-      {:else if segment.height > 5}
-        <div
-          aria-label={segment.timeGroup + ' ' + segment.count}
-          class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300"
-        />
-      {/if}
-    </div>
-  {/each}
-</div>
+      </div>
+    {/each}
+  </div>
+{/if}
 
 <style>
   #immich-scrubbable-scrollbar,

+ 0 - 5
web/src/lib/components/shared-components/scrollbar/segment-scrollbar-layout.ts

@@ -1,5 +0,0 @@
-export class SegmentScrollbarLayout {
-  height!: number;
-  timeGroup!: string;
-  count!: number;
-}