mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 04:05:39 -04:00
feat: mobile-web improvements - scrubber (#16856)
* feat: mobile-web improvements - scrubber * lint * cruft * lint * fix: thumb style --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
b5d5c40c69
commit
55b52ecbec
@ -5,7 +5,7 @@
|
|||||||
<!-- metadata:tags -->
|
<!-- metadata:tags -->
|
||||||
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { UpdatePayload } from 'vite';
|
import type { UpdatePayload } from 'vite';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
@ -82,6 +83,8 @@
|
|||||||
let bottomSectionHeight = 60;
|
let bottomSectionHeight = 60;
|
||||||
let leadout = $state(false);
|
let leadout = $state(false);
|
||||||
|
|
||||||
|
const usingMobileDevice = $derived(mobileDevice.hoverNone);
|
||||||
|
|
||||||
const scrollTo = (top: number) => {
|
const scrollTo = (top: number) => {
|
||||||
element?.scrollTo({ top });
|
element?.scrollTo({ top });
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
@ -714,7 +717,12 @@
|
|||||||
<!-- 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 h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
|
class={[
|
||||||
|
'scrollbar-hidden h-full overflow-y-auto outline-none',
|
||||||
|
{ 'm-0': isEmpty },
|
||||||
|
{ 'ml-4 tall:ml-0': !isEmpty },
|
||||||
|
{ 'mr-[60px]': !isEmpty && !usingMobileDevice },
|
||||||
|
]}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={assetStore.viewportHeight}
|
bind:clientHeight={assetStore.viewportHeight}
|
||||||
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
|
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
|
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
|
||||||
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
|
import { mdiPlay } from '@mdi/js';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -209,6 +213,62 @@
|
|||||||
|
|
||||||
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||||
};
|
};
|
||||||
|
const getTouch = (event: TouchEvent) => {
|
||||||
|
if (event.touches.length === 1) {
|
||||||
|
return event.touches[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const onTouchStart = (event: TouchEvent) => {
|
||||||
|
const touch = getTouch(event);
|
||||||
|
if (!touch) {
|
||||||
|
isHover = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
|
||||||
|
const isHoverScrollbar = elements.some(({ id }) => {
|
||||||
|
return id === 'immich-scrubbable-scrollbar' || id === 'time-label';
|
||||||
|
});
|
||||||
|
|
||||||
|
isHover = isHoverScrollbar;
|
||||||
|
|
||||||
|
if (isHoverScrollbar) {
|
||||||
|
handleMouseEvent({
|
||||||
|
clientY: touch.clientY,
|
||||||
|
isDragging: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
if (isHover) {
|
||||||
|
isHover = false;
|
||||||
|
}
|
||||||
|
handleMouseEvent({
|
||||||
|
clientY,
|
||||||
|
isDragging: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onTouchMove = (event: TouchEvent) => {
|
||||||
|
const touch = getTouch(event);
|
||||||
|
if (touch && isDragging) {
|
||||||
|
handleMouseEvent({
|
||||||
|
clientY: touch.clientY,
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
} else {
|
||||||
|
isHover = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onMount(() => {
|
||||||
|
const opts = {
|
||||||
|
passive: false,
|
||||||
|
};
|
||||||
|
globalThis.addEventListener('touchmove', onTouchMove, opts);
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener('touchmove', onTouchMove);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const usingMobileDevice = $derived(mobileDevice.hoverNone);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@ -216,6 +276,9 @@
|
|||||||
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
|
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
|
||||||
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
|
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
|
||||||
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||||
|
ontouchstart={onTouchStart}
|
||||||
|
ontouchend={onTouchEnd}
|
||||||
|
ontouchcancel={onTouchEnd}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -237,8 +300,9 @@
|
|||||||
onmouseenter={() => (isHover = true)}
|
onmouseenter={() => (isHover = true)}
|
||||||
onmouseleave={() => (isHover = false)}
|
onmouseleave={() => (isHover = false)}
|
||||||
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
|
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
|
||||||
|
draggable="false"
|
||||||
>
|
>
|
||||||
{#if hoverLabel && (isHover || isDragging)}
|
{#if !usingMobileDevice && hoverLabel && (isHover || isDragging)}
|
||||||
<div
|
<div
|
||||||
id="time-label"
|
id="time-label"
|
||||||
class={[
|
class={[
|
||||||
@ -251,8 +315,34 @@
|
|||||||
{hoverLabel}
|
{hoverLabel}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if usingMobileDevice && ((assetStore.scrolling && scrollHoverLabel) || isHover || isDragging)}
|
||||||
|
<div
|
||||||
|
id="time-label"
|
||||||
|
class="rounded-l-full w-[32px] pl-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none"
|
||||||
|
style:top="{scrollY + HOVER_DATE_HEIGHT - 25}px"
|
||||||
|
style:height="50px"
|
||||||
|
style:right="0"
|
||||||
|
style:position="absolute"
|
||||||
|
in:fade={{ duration: 200 }}
|
||||||
|
out:fade={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
<Icon path={mdiPlay} size="20" class="-rotate-90 relative top-[9px] -right-[2px]" />
|
||||||
|
<Icon path={mdiPlay} size="20" class="rotate-90 relative top-[1px] -right-[2px]" />
|
||||||
|
{#if (assetStore.scrolling && scrollHoverLabel) || isHover || isDragging}
|
||||||
|
<p
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
style:bottom={50 / 2 - 30 / 2 + 'px'}
|
||||||
|
style:right="36px"
|
||||||
|
style:width="fit-content"
|
||||||
|
class="truncate pointer-events-none absolute text-sm rounded-full w-[32px] py-2 px-4 text-white bg-immich-primary/90 dark:bg-gray-500 hover:cursor-pointer select-none font-semibold"
|
||||||
|
>
|
||||||
|
{scrollHoverLabel}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Scroll Position Indicator Line -->
|
<!-- Scroll Position Indicator Line -->
|
||||||
{#if !isDragging}
|
{#if !usingMobileDevice && !isDragging}
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
||||||
style:top="{scrollY + HOVER_DATE_HEIGHT}px"
|
style:top="{scrollY + HOVER_DATE_HEIGHT}px"
|
||||||
@ -280,21 +370,14 @@
|
|||||||
data-time-segment-bucket-date={segment.date}
|
data-time-segment-bucket-date={segment.date}
|
||||||
data-label={segment.dateFormatted}
|
data-label={segment.dateFormatted}
|
||||||
style:height={segment.height + 'px'}
|
style:height={segment.height + 'px'}
|
||||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
|
||||||
>
|
>
|
||||||
{#if segment.hasLabel}
|
{#if !usingMobileDevice && segment.hasLabel}
|
||||||
<div
|
<div class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono">
|
||||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
|
||||||
class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono"
|
|
||||||
>
|
|
||||||
{segment.date.year}
|
{segment.date.year}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if segment.hasDot}
|
{#if !usingMobileDevice && segment.hasDot}
|
||||||
<div
|
<div class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"></div>
|
||||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
|
||||||
class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"
|
|
||||||
></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
9
web/src/lib/stores/mobile-device.svelte.ts
Normal file
9
web/src/lib/stores/mobile-device.svelte.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
const hoverNone = new MediaQuery('hover: none');
|
||||||
|
|
||||||
|
export const mobileDevice = {
|
||||||
|
get hoverNone() {
|
||||||
|
return hoverNone.current;
|
||||||
|
},
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user