replace el-carousel with Carousel component

This commit is contained in:
pa
2026-01-11 21:55:19 +09:00
committed by Natsumi
parent 5fa2d4d465
commit 6222becd3d
11 changed files with 1184 additions and 104 deletions

870
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,7 @@
"electron": "^39.2.7",
"electron-builder": "^26.4.0",
"element-plus": "^2.13.1",
"embla-carousel-vue": "^8.6.0",
"esbuild-jest": "^0.5.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
@@ -76,6 +77,7 @@
"prettier": "^3.7.4",
"reka-ui": "^2.7.0",
"remixicon": "^4.8.0",
"sass-embedded": "^1.97.2",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",

View File

@@ -354,45 +354,24 @@
:indeterminate="true"
:percentage="100"
:stroke-width="3"
style="margin: 10px 0; max-width: 240px" />
<el-carousel
v-if="avatarDialog.galleryImages.length"
type="card"
:autoplay="false"
height="200px">
<el-carousel-item v-for="imageUrl in avatarDialog.galleryImages" :key="imageUrl">
<img
:src="imageUrl"
style="width: 100%; height: 100%; object-fit: contain"
@click="showFullscreenImageDialog(imageUrl)"
loading="lazy" />
<div
v-if="avatarDialog.ref.authorId === currentUser.id"
style="position: absolute; bottom: 35px; left: 38%">
<el-button
size="small"
:icon="Back"
circle
class="x-link"
style="margin-left: 0"
@click.stop="reorderAvatarGalleryImage(imageUrl, -1)"></el-button>
<el-button
size="small"
:icon="Right"
circle
class="x-link"
style="margin-left: 0"
@click.stop="reorderAvatarGalleryImage(imageUrl, 1)"></el-button>
<el-button
size="small"
:icon="Delete"
circle
class="x-link"
style="margin-left: 0"
@click.stop="deleteAvatarGalleryImage(imageUrl)"></el-button>
</div>
</el-carousel-item>
</el-carousel>
style="margin: 10px 0" />
<div class="mt-2 w-[80%] ml-20">
<Carousel v-if="avatarDialog.galleryImages.length" class="w-full">
<CarouselContent class="h-50">
<CarouselItem v-for="imageUrl in avatarDialog.galleryImages" :key="imageUrl">
<div class="relative h-50 w-full">
<img
:src="imageUrl"
style="width: 100%; height: 100%; object-fit: contain"
@click="showFullscreenImageDialog(imageUrl)"
loading="lazy" />
</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
</div>
<div v-if="avatarDialog.ref.publishedListings?.length">
<span class="name">{{ t('dialog.avatar.info.listings') }}</span>
@@ -560,6 +539,7 @@
User,
Warning
} from '@element-plus/icons-vue';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel';
import { CircleCheck, Ellipsis, RefreshCcw, Star, Trash2 } from 'lucide-vue-next';
import { computed, defineAsyncComponent, nextTick, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';

View File

@@ -0,0 +1,56 @@
<script setup>
import { cn } from '@/lib/utils';
import { useProvideCarousel } from './useCarousel';
const props = defineProps({
opts: { type: Object, required: false },
plugins: { type: null, required: false },
orientation: { type: String, required: false, default: 'horizontal' },
class: { type: null, required: false }
});
const emits = defineEmits(['init-api']);
const { canScrollNext, canScrollPrev, carouselApi, carouselRef, orientation, scrollNext, scrollPrev } =
useProvideCarousel(props, emits);
defineExpose({
canScrollNext,
canScrollPrev,
carouselApi,
carouselRef,
orientation,
scrollNext,
scrollPrev
});
function onKeyDown(event) {
const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
if (event.key === prevKey) {
event.preventDefault();
scrollPrev();
return;
}
if (event.key === nextKey) {
event.preventDefault();
scrollNext();
}
}
</script>
<template>
<div
data-slot="carousel"
:class="cn('relative', props.class)"
role="region"
aria-roledescription="carousel"
tabindex="0"
@keydown="onKeyDown">
<slot :can-scroll-next :can-scroll-prev :carousel-api :carousel-ref :orientation :scroll-next :scroll-prev />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { cn } from '@/lib/utils';
import { useCarousel } from './useCarousel';
defineOptions({
inheritAttrs: false
});
const props = defineProps({
class: { type: null, required: false }
});
const { carouselRef, orientation } = useCarousel();
</script>
<template>
<div ref="carouselRef" data-slot="carousel-content" class="overflow-hidden">
<div
:class="cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', props.class)"
v-bind="$attrs">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from '@/lib/utils';
import { useCarousel } from './useCarousel';
const props = defineProps({
class: { type: null, required: false }
});
const { orientation } = useCarousel();
</script>
<template>
<div
data-slot="carousel-item"
role="group"
aria-roledescription="slide"
:class="cn('min-w-0 shrink-0 grow-0 basis-full', orientation === 'horizontal' ? 'pl-4' : 'pt-4', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { ArrowRight } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useCarousel } from './useCarousel';
const props = defineProps({
variant: { type: String, required: false, default: 'outline' },
size: { type: String, required: false, default: 'icon' },
class: { type: null, required: false }
});
const { orientation, canScrollNext, scrollNext } = useCarousel();
</script>
<template>
<Button
data-slot="carousel-next"
:disabled="!canScrollNext"
:class="
cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
props.class
)
"
:variant="variant"
:size="size"
@click="scrollNext">
<slot>
<ArrowRight />
<span class="sr-only">Next Slide</span>
</slot>
</Button>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { ArrowLeft } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useCarousel } from './useCarousel';
const props = defineProps({
variant: { type: String, required: false, default: 'outline' },
size: { type: String, required: false, default: 'icon' },
class: { type: null, required: false }
});
const { orientation, canScrollPrev, scrollPrev } = useCarousel();
</script>
<template>
<Button
data-slot="carousel-previous"
:disabled="!canScrollPrev"
:class="
cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
props.class
)
"
:variant="variant"
:size="size"
@click="scrollPrev">
<slot>
<ArrowLeft />
<span class="sr-only">Previous Slide</span>
</slot>
</Button>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Carousel } from './Carousel.vue';
export { default as CarouselContent } from './CarouselContent.vue';
export { default as CarouselItem } from './CarouselItem.vue';
export { default as CarouselNext } from './CarouselNext.vue';
export { default as CarouselPrevious } from './CarouselPrevious.vue';
export { useCarousel } from './useCarousel';

View File

@@ -0,0 +1,63 @@
import { onMounted, ref } from 'vue';
import { createInjectionState } from '@vueuse/core';
import emblaCarouselVue from 'embla-carousel-vue';
const [useProvideCarousel, useInjectCarousel] = createInjectionState(
(props, emits) => {
const { opts, orientation, plugins } = props;
const [emblaNode, emblaApi] = emblaCarouselVue(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y'
},
plugins
);
function scrollPrev() {
emblaApi.value?.scrollPrev();
}
function scrollNext() {
emblaApi.value?.scrollNext();
}
const canScrollNext = ref(false);
const canScrollPrev = ref(false);
function onSelect(api) {
canScrollNext.value = api?.canScrollNext() || false;
canScrollPrev.value = api?.canScrollPrev() || false;
}
onMounted(() => {
if (!emblaApi.value) return;
emblaApi.value?.on('init', onSelect);
emblaApi.value?.on('reInit', onSelect);
emblaApi.value?.on('select', onSelect);
emits('init-api', emblaApi.value);
});
return {
carouselRef: emblaNode,
carouselApi: emblaApi,
canScrollPrev,
canScrollNext,
scrollPrev,
scrollNext,
orientation
};
}
);
function useCarousel() {
const carouselState = useInjectCarousel();
if (!carouselState)
throw new Error('useCarousel must be used within a <Carousel />');
return carouselState;
}
export { useCarousel, useProvideCarousel };

View File

@@ -111,36 +111,39 @@
:hint="screenshotMetadataDialog.metadata.author.displayName"
style="color: var(--el-text-color-secondary); font-family: monospace" />
<br />
<el-carousel
ref="screenshotMetadataCarouselRef"
:interval="0"
:initial-index="1"
indicator-position="none"
arrow="always"
height="600px"
style="margin-top: 10px"
@change="screenshotMetadataCarouselChange">
<el-carousel-item>
<img
class="x-link"
:src="screenshotMetadataDialog.metadata.previousFilePath"
style="width: 100%; height: 100%; object-fit: contain" />
</el-carousel-item>
<el-carousel-item>
<img
class="x-link"
:src="screenshotMetadataDialog.metadata.filePath"
style="width: 100%; height: 100%; object-fit: contain"
@click="showFullscreenImageDialog(screenshotMetadataDialog.metadata.filePath)" />
</el-carousel-item>
<el-carousel-item>
<img
class="x-link"
:src="screenshotMetadataDialog.metadata.nextFilePath"
style="width: 100%; height: 100%; object-fit: contain" />
</el-carousel-item>
</el-carousel>
<br />
<div class="my-2 w-[95%] ml-6.5">
<Carousel :opts="{ loop: false }" @init-api="handleScreenshotMetadataCarouselInit">
<CarouselContent class="h-150">
<CarouselItem>
<div class="h-150 w-full">
<img
class="x-link"
:src="screenshotMetadataDialog.metadata.previousFilePath"
style="width: 100%; height: 100%; object-fit: contain" />
</div>
</CarouselItem>
<CarouselItem>
<div class="h-150 w-full">
<img
class="x-link"
:src="screenshotMetadataDialog.metadata.filePath"
style="width: 100%; height: 100%; object-fit: contain"
@click="showFullscreenImageDialog(screenshotMetadataDialog.metadata.filePath)" />
</div>
</CarouselItem>
<CarouselItem>
<div class="h-150 w-full">
<img
class="x-link"
:src="screenshotMetadataDialog.metadata.nextFilePath"
style="width: 100%; height: 100%; object-fit: contain" />
</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
<template v-if="screenshotMetadataDialog.metadata.error">
<pre
style="white-space: pre-wrap; font-size: 12px"
@@ -161,6 +164,7 @@
<script setup>
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel';
import { reactive, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { storeToRefs } from 'pinia';
@@ -219,11 +223,20 @@
});
const screenshotMetadataSearchInputs = ref(0);
const screenshotMetadataCarouselRef = ref(null);
const screenshotMetadataCarouselApi = ref(null);
const ignoreCarouselSelect = ref(false);
const handleComponentKeyup = (event) => {
const carouselNavigation = { ArrowLeft: 0, ArrowRight: 2 }[event.key];
if (typeof carouselNavigation !== 'undefined' && props.isScreenshotMetadataDialogVisible) {
if (screenshotMetadataCarouselApi.value) {
if (event.key === 'ArrowLeft') {
screenshotMetadataCarouselApi.value.scrollPrev();
} else {
screenshotMetadataCarouselApi.value.scrollNext();
}
return;
}
screenshotMetadataCarouselChange(carouselNavigation);
}
};
@@ -407,9 +420,7 @@
getAndDisplayScreenshot(D.metadata.filePath);
}
}
if (typeof screenshotMetadataCarouselRef.value !== 'undefined') {
screenshotMetadataCarouselRef.value.setActiveItem(1);
}
resetCarouselIndex();
if (fullscreenImageDialog.value.visible) {
// TODO
@@ -451,13 +462,38 @@
}
}
if (typeof screenshotMetadataCarouselRef.value !== 'undefined') {
screenshotMetadataCarouselRef.value.setActiveItem(1);
}
resetCarouselIndex();
D.searchIndex = searchIndex;
}
function handleScreenshotMetadataCarouselInit(api) {
screenshotMetadataCarouselApi.value = api;
api.on('select', handleCarouselSelect);
api.on('reInit', handleCarouselSelect);
resetCarouselIndex();
}
function handleCarouselSelect() {
if (ignoreCarouselSelect.value || !screenshotMetadataCarouselApi.value) {
return;
}
const index = screenshotMetadataCarouselApi.value.selectedScrollSnap();
screenshotMetadataCarouselChange(index);
}
function resetCarouselIndex() {
const api = screenshotMetadataCarouselApi.value;
if (!api) {
return;
}
ignoreCarouselSelect.value = true;
api.scrollTo(1, true);
setTimeout(() => {
ignoreCarouselSelect.value = false;
}, 0);
}
async function getAndDisplayScreenshot(path, needsCarouselFiles = true) {
const metadata = await AppApi.GetScreenshotMetadata(path);
displayScreenshotMetadata(metadata, needsCarouselFiles);