scrollbar.svelte 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. <script lang="ts">
  2. import type { AssetStore, AssetBucket } from '$lib/stores/assets.store';
  3. import type { DateTime } from 'luxon';
  4. import { fromLocalDateTime } from '$lib/utils/timeline-util';
  5. import { createEventDispatcher } from 'svelte';
  6. export let timelineY = 0;
  7. export let height = 0;
  8. export let assetStore: AssetStore;
  9. let isHover = false;
  10. let isDragging = false;
  11. let isAnimating = false;
  12. let hoverLabel = '';
  13. let clientY = 0;
  14. let windowHeight = 0;
  15. const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height;
  16. const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height);
  17. const HOVER_DATE_HEIGHT = 30;
  18. const MIN_YEAR_LABEL_DISTANCE = 16;
  19. $: hoverY = height - windowHeight + clientY;
  20. $: scrollY = toScrollY(timelineY);
  21. class Segment {
  22. public count = 0;
  23. public height = 0;
  24. public timeGroup = '';
  25. public date!: DateTime;
  26. public hasLabel = false;
  27. }
  28. const calculateSegments = (buckets: AssetBucket[]) => {
  29. let height = 0;
  30. let prev: Segment;
  31. return buckets.map((bucket) => {
  32. const segment = new Segment();
  33. segment.count = bucket.assets.length;
  34. segment.height = toScrollY(bucket.bucketHeight);
  35. segment.timeGroup = bucket.bucketDate;
  36. segment.date = fromLocalDateTime(segment.timeGroup);
  37. if (prev && prev!.date.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
  38. prev.hasLabel = true;
  39. height = 0;
  40. }
  41. height += segment.height;
  42. prev = segment;
  43. return segment;
  44. });
  45. };
  46. $: segments = calculateSegments($assetStore.buckets);
  47. const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
  48. const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
  49. const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
  50. const wasDragging = isDragging;
  51. isDragging = event.isDragging ?? isDragging;
  52. clientY = event.clientY;
  53. if (wasDragging === false && isDragging) {
  54. scrollTimeline();
  55. }
  56. if (!isDragging || isAnimating) {
  57. return;
  58. }
  59. isAnimating = true;
  60. window.requestAnimationFrame(() => {
  61. scrollTimeline();
  62. isAnimating = false;
  63. });
  64. };
  65. </script>
  66. <svelte:window bind:innerHeight={windowHeight} />
  67. <!-- svelte-ignore a11y-no-static-element-interactions -->
  68. {#if $assetStore.timelineHeight > height}
  69. <div
  70. id="immich-scrubbable-scrollbar"
  71. class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
  72. style:width={isDragging ? '100vw' : '60px'}
  73. style:height={height + 'px'}
  74. style:background-color={isDragging ? 'transparent' : 'transparent'}
  75. draggable="false"
  76. on:mouseenter={() => (isHover = true)}
  77. on:mouseleave={() => {
  78. isHover = false;
  79. isDragging = false;
  80. }}
  81. on:mouseenter={({ clientY, buttons }) => handleMouseEvent({ clientY, isDragging: !!buttons })}
  82. on:mousemove={({ clientY }) => handleMouseEvent({ clientY })}
  83. on:mousedown={({ clientY }) => handleMouseEvent({ clientY, isDragging: true })}
  84. on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
  85. >
  86. {#if isHover}
  87. <div
  88. 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"
  89. style:top="{Math.max(hoverY - HOVER_DATE_HEIGHT, 0)}px"
  90. >
  91. {hoverLabel}
  92. </div>
  93. {/if}
  94. <!-- Scroll Position Indicator Line -->
  95. {#if !isDragging}
  96. <div
  97. class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
  98. style:top="{scrollY}px"
  99. />
  100. {/if}
  101. <!-- Time Segment -->
  102. {#each segments as segment}
  103. {@const label = `${segment.date.toLocaleString({ month: 'short' })} ${segment.date.year}`}
  104. <!-- svelte-ignore a11y-no-static-element-interactions -->
  105. <div
  106. id="time-segment"
  107. class="relative"
  108. style:height={segment.height + 'px'}
  109. aria-label={segment.timeGroup + ' ' + segment.count}
  110. on:mousemove={() => (hoverLabel = label)}
  111. >
  112. {#if segment.hasLabel}
  113. <div
  114. aria-label={segment.timeGroup + ' ' + segment.count}
  115. class="absolute right-0 bottom-0 z-10 pr-5 text-[12px] dark:text-immich-dark-fg font-immich-mono"
  116. >
  117. {segment.date.year}
  118. </div>
  119. {:else if segment.height > 5}
  120. <div
  121. aria-label={segment.timeGroup + ' ' + segment.count}
  122. class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300"
  123. />
  124. {/if}
  125. </div>
  126. {/each}
  127. </div>
  128. {/if}
  129. <style>
  130. #immich-scrubbable-scrollbar,
  131. #time-segment {
  132. contain: layout;
  133. }
  134. </style>