Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"devDependencies": {
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@rhapsodic/eslint-config": "^1.0.5",
"@rhapsodic/eslint-config": "^4.0.0",
"@rhapsodic/stylelint-config": "^1.0.1",
"@types/node": "^25.9.5",
"@vitejs/plugin-vue": "^6.0.8",
Expand Down
442 changes: 286 additions & 156 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

69 changes: 40 additions & 29 deletions src/components/carousel/carousel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@

<script setup lang="ts">
import {
useTemplateRef, ref, watch, nextTick, onMounted, onBeforeUnmount,
useTemplateRef,
ref,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from 'vue';
import { getNormalizedWheelDominantAxisDelta } from '../../utils/wheel-delta';

Expand All @@ -35,11 +40,18 @@ const props = defineProps<Props>();
const carouselContainer = useTemplateRef<HTMLDivElement>('carouselContainer');
const carouselTrack = useTemplateRef<HTMLDivElement>('carouselTrack');
const internalActiveIndex = ref<number | undefined>();
const isActiveSwitchRecenteringPending = ref(false);
const activeSwitchRecenteringPending = ref(false);
const activeSwitchMaxWidthTransitionsInFlight = ref(0);
let firstFallbackFrameId: number | null = null;
let secondFallbackFrameId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
const fallbackFrameIds: {
first: number | null;
second: number | null;
} = {
first: null,
second: null,
};
const lifecycleState: { resizeObserver: ResizeObserver | null } = {
resizeObserver: null,
};

function getCarouselItems(): HTMLElement[] {
if (!carouselTrack.value) {
Expand Down Expand Up @@ -105,19 +117,19 @@ function onResize(): void {
}

function cancelFallbackRecentering(): void {
if (firstFallbackFrameId !== null) {
cancelAnimationFrame(firstFallbackFrameId);
firstFallbackFrameId = null;
if (fallbackFrameIds.first !== null) {
cancelAnimationFrame(fallbackFrameIds.first);
fallbackFrameIds.first = null;
}

if (secondFallbackFrameId !== null) {
cancelAnimationFrame(secondFallbackFrameId);
secondFallbackFrameId = null;
if (fallbackFrameIds.second !== null) {
cancelAnimationFrame(fallbackFrameIds.second);
fallbackFrameIds.second = null;
}
}

function finalizeSwitchRecenteringIfSettled(): void {
if (!isActiveSwitchRecenteringPending.value) {
if (!activeSwitchRecenteringPending.value) {
return;
}

Expand All @@ -126,17 +138,17 @@ function finalizeSwitchRecenteringIfSettled(): void {
}

centerCurrentlyActiveItem('smooth');
isActiveSwitchRecenteringPending.value = false;
activeSwitchRecenteringPending.value = false;
}

function scheduleFallbackRecenteringForSwitch(): void {
cancelFallbackRecentering();

firstFallbackFrameId = requestAnimationFrame(() => {
firstFallbackFrameId = null;
fallbackFrameIds.first = requestAnimationFrame(() => {
fallbackFrameIds.first = null;

secondFallbackFrameId = requestAnimationFrame(() => {
secondFallbackFrameId = null;
fallbackFrameIds.second = requestAnimationFrame(() => {
fallbackFrameIds.second = null;
finalizeSwitchRecenteringIfSettled();
});
});
Expand All @@ -156,7 +168,7 @@ function isCarouselItemMaxWidthTransition(event: TransitionEvent): boolean {
}

function onCarouselTrackTransitionRun(event: TransitionEvent): void {
if (!isActiveSwitchRecenteringPending.value) {
if (!activeSwitchRecenteringPending.value) {
return;
}

Expand All @@ -168,7 +180,7 @@ function onCarouselTrackTransitionRun(event: TransitionEvent): void {
}

function onCarouselTrackTransitionEnd(event: TransitionEvent): void {
if (!isActiveSwitchRecenteringPending.value) {
if (!activeSwitchRecenteringPending.value) {
return;
}

Expand Down Expand Up @@ -216,12 +228,11 @@ function updateEdgePadding(items: HTMLElement[]): void {
watch(() => props.activeItemId, (_, previousActiveItemId) => {
const scrollBehavior: ScrollBehavior = previousActiveItemId === undefined ? 'auto' : 'smooth';
if (scrollBehavior === 'smooth') {
isActiveSwitchRecenteringPending.value = true;
activeSwitchMaxWidthTransitionsInFlight.value = 0;
activeSwitchRecenteringPending.value = true;
} else {
isActiveSwitchRecenteringPending.value = false;
activeSwitchMaxWidthTransitionsInFlight.value = 0;
activeSwitchRecenteringPending.value = false;
}
activeSwitchMaxWidthTransitionsInFlight.value = 0;

nextTick(() => {
centerCurrentlyActiveItem(scrollBehavior);
Expand All @@ -242,27 +253,27 @@ onMounted(() => {
window.addEventListener('resize', onResize);

if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
lifecycleState.resizeObserver = new ResizeObserver(() => {
const items = getCarouselItems();
updateEdgePadding(items);
centerCurrentlyActiveItem(isActiveSwitchRecenteringPending.value ? 'smooth' : 'auto');
centerCurrentlyActiveItem(activeSwitchRecenteringPending.value ? 'smooth' : 'auto');
});

if (carouselContainer.value) {
resizeObserver.observe(carouselContainer.value);
lifecycleState.resizeObserver.observe(carouselContainer.value);
}

if (carouselTrack.value) {
resizeObserver.observe(carouselTrack.value);
lifecycleState.resizeObserver.observe(carouselTrack.value);
}
}
});

onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
cancelFallbackRecentering();
resizeObserver?.disconnect();
resizeObserver = null;
lifecycleState.resizeObserver?.disconnect();
lifecycleState.resizeObserver = null;
});
</script>

Expand Down
5 changes: 4 additions & 1 deletion src/components/carousel/item/item.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
</template>

<script setup lang="ts">
import type { VuewerCarouselItemEmits, VuewerCarouselItemProps } from '.';
import type {
VuewerCarouselItemEmits,
VuewerCarouselItemProps,
} from '.';

withDefaults(defineProps<VuewerCarouselItemProps>(), {
isActive: false,
Expand Down
36 changes: 22 additions & 14 deletions src/components/vuewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
@pointermove="onViewerPointerMove"
@pointerup="onViewerPointerUp"
@pointercancel="onViewerPointerCancel"
@wheel="onWheel"
>
<div class="vuewer__overlay" />
<div
Expand Down Expand Up @@ -52,9 +53,9 @@
<VuewerCloseButton @click="emit('close')" />
</div>

<template v-if="hasNonInitialZoom || images.length > 1">
<template v-if="nonInitialZoom || images.length > 1">
<div class="vuewer__navigation">
<template v-if="hasNonInitialZoom">
<template v-if="nonInitialZoom">
<VuewerZoomControls
:zoom-percentage="zoomPercentage"
@reset="resetScale"
Expand Down Expand Up @@ -88,7 +89,12 @@
<script setup lang="ts">
import { useIdle } from '@vueuse/core';
import {
computed, useAttrs, ref, watch, onMounted, onBeforeUnmount,
computed,
useAttrs,
ref,
watch,
onMounted,
onBeforeUnmount,
} from 'vue';

import VuewerNavigationButton from './navigation/button/button.vue';
Expand All @@ -106,7 +112,11 @@ import { useWheelScrollTuning } from '../composables/wheel-scroll-tuning';
import { useMouseClickClose } from '../composables/mouse-click-close';
import { useTouchSwipeDismiss } from '../composables/touch-swipe-dismiss';

import type { VuewerProps, VuewerEmits, VuewerImage } from '.';
import type {
VuewerProps,
VuewerEmits,
VuewerImage,
} from '.';

interface ImageItem {
id: number;
Expand Down Expand Up @@ -176,25 +186,25 @@ const mouseClickClose = useMouseClickClose({
});
const contentRef = mouseClickClose.contentRef;

const hasNonInitialZoom = computed(() => Math.abs(zoom.imageScale.value - 1) > 0.001);
const nonInitialZoom = computed(() => Math.abs(zoom.imageScale.value - 1) > 0.001);
const zoomPercentage = computed(() => Math.round(zoom.imageScale.value * 100));
const pan = useVuewerPan({
viewerRef,
imageRef: activeImageRef,
imageScale: zoom.imageScale,
});

const isTouchGestureNavigationEnabled = computed(() => !pan.isImagePannable.value);
const touchGestureNavigationEnabled = computed(() => !pan.isImagePannable.value);
const touchSwipeDismiss = useTouchSwipeDismiss({
viewerRef,
isEnabled: isTouchGestureNavigationEnabled,
isEnabled: touchGestureNavigationEnabled,
onDismiss: () => emit('close'),
});

const swipeNavigation = useSwipeNavigation({
viewerRef,
imageRef: activeImageRef,
isSwipeEnabled: isTouchGestureNavigationEnabled,
isSwipeEnabled: touchGestureNavigationEnabled,
onSwipeLeft: () => goToNextImage(),
onSwipeRight: () => goToPrevImage(),
});
Expand Down Expand Up @@ -282,8 +292,8 @@ const imageViewerKeyboardActions: Record<ImageViewerKeyboardActions, () => void>
function onKeyDown(event: KeyboardEvent): void {
const keyCode = event.key.toLowerCase() as ImageViewerKeyboardActions;

if (keyCode in imageViewerKeyboardActions) {
const action = imageViewerKeyboardActions[keyCode];
const action = imageViewerKeyboardActions[keyCode];
if (action) {
action();
}
}
Expand Down Expand Up @@ -332,13 +342,11 @@ watch(imagesMap, (newMap) => {
}, { immediate: true });

onMounted(() => {
globalThis.addEventListener('keydown', onKeyDown);
globalThis.addEventListener('wheel', onWheel, { passive: false });
addEventListener('keydown', onKeyDown);
});

onBeforeUnmount(() => {
globalThis.removeEventListener('keydown', onKeyDown);
globalThis.removeEventListener('wheel', onWheel);
removeEventListener('keydown', onKeyDown);
});
</script>

Expand Down
10 changes: 5 additions & 5 deletions src/composables/mouse-click-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ export function useMouseClickClose(options: UseMouseClickCloseOptions = {}) {
const closePointerId = ref<number>();
const closeStartClientX = ref(0);
const closeStartClientY = ref(0);
const didClosePointerMove = ref(false);
const closePointerMoved = ref(false);

function resetClosePointerState(): void {
closePointerId.value = undefined;
didClosePointerMove.value = false;
closePointerMoved.value = false;
}

function isPointerTargetInsideContent(target: EventTarget | null): boolean {
Expand Down Expand Up @@ -63,18 +63,18 @@ export function useMouseClickClose(options: UseMouseClickCloseOptions = {}) {
}

function onViewerPointerMove(event: PointerEvent): void {
if (closePointerId.value !== event.pointerId || didClosePointerMove.value) {
if (closePointerId.value !== event.pointerId || closePointerMoved.value) {
return;
}

if (hasClosePointerMoved(event)) {
didClosePointerMove.value = true;
closePointerMoved.value = true;
}
}

function onViewerPointerUp(event: PointerEvent): void {
const isSamePointer = closePointerId.value === event.pointerId;
const hasPointerStayedStill = !didClosePointerMove.value && !hasClosePointerMoved(event);
const hasPointerStayedStill = !closePointerMoved.value && !hasClosePointerMoved(event);
const shouldClose = (
isSamePointer
&& hasPointerStayedStill
Expand Down
20 changes: 12 additions & 8 deletions src/composables/pan.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Ref } from 'vue';
import {
computed, onBeforeUnmount, onMounted, ref, watch,
computed,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';

import { clamp } from '../utils/math';
Expand Down Expand Up @@ -73,8 +77,8 @@ export function useVuewerPan({
const panPointer = useActivePointer();
const touchPointerLifecycle = useTouchPointerLifecycle();

const isImagePannable = computed(() => maxPanOffsetX.value > 0 || maxPanOffsetY.value > 0);
const isImageDragging = computed(() => panPointer.getActivePointerId() !== undefined);
const imagePannable = computed(() => maxPanOffsetX.value > 0 || maxPanOffsetY.value > 0);
const imageDragging = computed(() => panPointer.getActivePointerId() !== undefined);
const imageTransform = computed(() => `translate3d(${imageOffsetX.value}px, ${imageOffsetY.value}px, 0) scale(${imageScale.value})`);

function resetImagePanPosition(): void {
Expand Down Expand Up @@ -211,7 +215,7 @@ export function useVuewerPan({
}

function canStartPanFromPointer(event: PointerEvent): boolean {
if (!isImagePannable.value) {
if (!imagePannable.value) {
return false;
}

Expand Down Expand Up @@ -298,20 +302,20 @@ export function useVuewerPan({
});

onMounted(() => {
globalThis.addEventListener('resize', onResize);
addEventListener('resize', onResize);
recalculatePanBounds();
});

onBeforeUnmount(() => {
clearCurrentImagePanState();
globalThis.removeEventListener('resize', onResize);
removeEventListener('resize', onResize);
});

return {
onScaleChange,
imageTransform,
isImageDragging,
isImagePannable,
isImageDragging: imageDragging,
isImagePannable: imagePannable,
onImageLoad,
onViewerPointerDown,
onViewerPointerMove,
Expand Down
Loading
Loading