우선 완성된 컴포넌트는 나의 Github 리포지토리에 올려뒀다: https://github.com/hangminlee/ImageViewer-Svelte
이번 글은 어떻게 이미지 뷰어 컴포넌트가 작동하는지에 대한 기록이다.
배경
우선 이 컴포넌트를 만든 이유는 내 블로그 글의 이미지를 보다 심도 있게 볼 수 있는 장치가 필요할 것 같아서였다.
사실 이미지를 이렇게 크게 보여줄 필요까지는 없긴 한데, 있으면 좋을 것 같다는 마음에 적용을 시킨 것이다.
내 블로그 게시글은 작성 시 마크다운 파일로 올라가고 글을 읽을 때는 그것을 HTML로 렌더링해주는 @humanspeak/svelte-markdown 이라는 패키지를 사용한다. 그리고 저속 네트워크에서 빠른 이미지 로딩을 위해 저화질 이미지를 렌더링해주기 위해 커스텀 렌더러를 적용시켰다.
커스텀 렌더러 - 이미지에 동작을 추가하고 렌더링된 모든 이미지에 대한 상태 초기화
커스텀 렌더러 전체 코드 (CustomImage.svelte) :
<script lang="ts">
import { imgViewerStatusStore } from "@routes/client-config.svelte";
import { onDestroy, onMount } from "svelte";
import { get } from "svelte/store";
import { page } from "$app/state";
const { src, alt, width, height } = $props();
const containerWidth = $derived(width !== 'auto' ? `max-width: min(${width}px, 100%);` : '');
const aspectRatio = $derived(width !== 'auto' && height !== 'auto' ? `aspect-ratio: ${width} / ${height};` : '');
let newSearchParams = $derived.by(()=>{
const url = new URL(page.url);
const searchParams = url.searchParams;
searchParams.set('imgview', src);
return searchParams.toString();
});
async function imgPreview(event: Event) {
event.preventDefault();
if (!src) return;
imgViewerStatusStore.update((status)=>({
...status,
isOpen: true,
currentSrc: src,
}));
}
onMount(() => {
if (src) {
imgViewerStatusStore.update((status) => ({
...status,
images: [...status.images, { src, alt, uuid: crypto.randomUUID() }]
}));
}
});
onDestroy(() => {
get(imgViewerStatusStore).images.length && imgViewerStatusStore.update((status) => ({
...status,
images: []
}));
});
</script>
<a class="img-container" style="background-image: url({src}/?res=low);{containerWidth}{aspectRatio}" href={`?${newSearchParams}`} onclick={imgPreview}>
<img {src} {alt} {width} {height} loading="lazy" fetchpriority="low" />
</a>이 커스텀 렌더러 컴포넌트에 하나하나 이미지 뷰어를 적용시킬 수도 있었겠지만 그러면 너무 비효율적이고, 뷰 페이지에서 모든 이미지 정보를 확인하고 한번에 보여주는 편이 훨씬 효율적이기 때문에 렌더링 시에 이미지 정보를 컨텍스트 스토어에 저장하고 이미지 뷰어 컴포넌트에서 해당 스토어로부터 이미지 목록을 받아서 렌더링할 수 있도록 구성했다.
이미지를 클릭하면 직접 이미지 뷰어 컴포넌트를 렌더링하는 것이 아니라, 이미지 뷰어 컴포넌트가 렌더링 될 수 있도록 스토어의 상태만을 업데이트하도록 구성했다.
페이지가 이동하여 이미지 컴포넌트가 파괴될 경우 스토어에서도 이미지 배열 목록을 초기화해 다른 페이지의 이미지 목록과 섞이지 않도록 구성했다. 모든 컴포넌트에 대해 onDestroy가 실행되므로 다소 효율이 떨어지고 더 현명한 방법이 있었을 것도 같지만, 이렇게 구성해야 책임소지가 더 직관적으로 파악될 것 같았다.
또한, Javascript를 사용할 수 없는 환경에서 SSR 렌더링만으로도 컨트롤이 간소화된 이미지 뷰어를 사용할 수 있도록 현재 페이지 상태를 구독하여 이미지 클릭 시 뒤에 searchParams를 붙여 페이지 위에 이미지 뷰어가 떠있는 것처럼 보일 수 있도록 구성했다.
이것이 나는 SvelteKit의 장점이라고 생각한다. 생각보다 간단하게 SSR로 모달을 흉내낼 수 있기 때문이다. 엄밀히 따지면 모달은 아니지만, 같은 페이지 뷰라도 URL의 정보를 가지고 서버에서 미리 렌더링을 추가로 넣어줄 수 있기 때문이다.
그리고 그 과정이 매우 간편하다. 물론 다른 SSR 지원 프레임워크도 이것이 가능하지만, 그 직관성은 Svelte가 매우 편리하다고 생각한다.
컨텍스트 스토어 - 페이지 뷰와 이미지 상태 공유
어쨌든 마크다운 렌더러를 통해 이미지 컴포넌트가 렌더링 된 이후부터는 해당 컴포넌트는 컴포넌트 파괴 시 이외에 과도한 책임을 지면 안 된다.
다시 말해, 이미지 컴포넌트는 어딘가의 목록에다가만 이미지 정보를 넘겨주고 끝이고, 이미지 뷰어를 띄우는 것은 이미지 뷰어 컴포넌트의 역할이라는 것이다.
따라서 별도의 컨텍스트 스토어를 만들고 커스텀 렌더러에서는 해당 스토어에 렌더링 된 자기 자신의 이미지 정보를 넘겨주도록 구성했다.
컨텍스트 스토어 선언 (client-config.svelte.ts):
import { writable } from "svelte/store";
export const imgViewerStatusStore = writable({
isOpen: false,
images: [] as {src: string, alt: string, uuid: string}[],
currentSrc: undefined as string | undefined
});isOpen: 이미지 뷰어가 열려 있는지 닫혀 있는지 상태를 기록한다. 이 상태를 이용해 페이지 뷰에서 이미지 뷰어를 모달로 띄운다.images: 전체 이미지 목록을 객체 배열로 저장한다.src는 이미지의 주소를,alt는 이미지의 설명을,uuid는 임의의 이미지 고유 번호가 기록된다.currentSrc: 현재 이미지 뷰어에서 보이고 있는 이미지의src값을 기록한다.
이미지 별로 고유의 uuid 값이 부여되므로 currentSrc는 필요가 없을지도 모르지만, 이미지 개수가 부족한 경우에는 이미지에 uuid를 별도의 값으로 채워 넣어서 부족한 이미지를 복제해서 목록을 만들기 때문에 이 경우에는 src 값으로 현재 이미지를 판별하는 것이 타당하다고 생각했다.
이미지 뷰어 컴포넌트 구조를 설명할 때 다시 한 번 다루도록 하겠다.
페이지 뷰 - 이미지 뷰어 컴포넌트 렌더링
위에서 말했다시피, 이미지 컴포넌트는 이미지 뷰어를 직접 호출할 책임을 져서는 안된다. 그렇게 코딩해도 되기는 하겠지만, 예를 들어 이미지 개수가 엄청 많아졌다고 했을 때, 해당 이미지들이 렌더링 될때마다 이미지 뷰어를 호출하는 이벤트가 각각 붙게 된다. 그것은 아무래도 효율이 나쁘기 때문에, 차라리 이미지 정보를 어딘가에서 가져와 페이지 뷰를 통해 렌더링 하는 것이 낫다.
그렇기 때문에 이미지 뷰어 컴포넌트를 렌더링 하는 것은 페이지 뷰에서 하는 것이 가장 적절하다.
페이지 뷰 컴포넌트 배치 (+page.svelte):
<script lang="ts">
import { imgViewerStatusStore } from "@routes/client-config.svelte";
import ImageViewer from "@components/board/ImageViewer.svelte";
import { page } from "$app/state";
let SSRImageView = $derived(page.url.searchParams.get('imgview'));
let imgViewerStatus: ImageViewerStatus = $state({
isOpen: false,
currentSrc: undefined,
images: []
});
imgViewerStatusStore.subscribe(value => {
imgViewerStatus = value;
});
</script>
...
{#if imgViewerStatus.isOpen || SSRImageView}
<ImageViewer bind:imgViewerStatus={imgViewerStatus} SSRImageView={SSRImageView} />
{/if}
- 먼저 컨텍스트 스토어로 익스포트 한
imgViewerStatusStore변수를 임포트한다. - 그리고 이미지 뷰어 컴포넌트
ImageViewer도 임포트한다. - 스토어로부터 구독한 상태를 페이지 뷰 내부에서 사용하기 위해 초기 상태를 별도 변수
imgViewerStatus로 저장한다.
자바스크립트를 사용하지 않고서도 이미지 뷰어를 사용할 수 있도록 URL의 GET 파라미터를 확인하여 이미지 뷰어를 렌더링 할 수 있도록 한다.
이제 스토어로부터 구독하여 저장한 imgViewerStatus 변수의 isOpen 값의 참 여부와 SSRImageView 값이 있는지 확인하여 있으면 ImageViewer 컴포넌트를 렌더링한다. 컴포넌트의 Props로 imgViewerStatus와 SSRImageView 값을 넘긴다.
이미지 뷰어 컴포넌트 - 오늘의 주인공
이미지 뷰어 컴포넌트 그 자체라 내용이 좀 길긴 한데... 핵심만 보자면 다음과 같은 흐름이 된다:
Props로부터 이미지 목록을 불러온다.currentSrc값을 통해 현재 클릭한 이미지가 무엇인지 확인해currentImage에 저장한다.- 현재 이미지 앞 뒤에 있는 이미지 데이터까지 포함해 값이 3개 있는 배열을 만든다.
- 전체 이미지 목록이 3개 미만인 경우, 이미지를 순환해서 보여줄 수 있도록 부족한 이미지는 기존에 있는 이미지 데이터로 하여 배열에 채워 넣는다.
{#each ... as ...}블록을 이용해 이미지 배열을 가져와 화면에 렌더링하되, 이미지를 감싸는 부모 컨테이너는 300%의 너비를 갖도록 하고,flex로 표시되도록 한다. 모두 균등한 너비를 가질 수 있도록min-width: 100%을 이미지에 적용시킨다.animate:flip속성을 적용해 이미지가 전환될 때 애니메이션이 작동되도록 한다.- 앞 / 뒤 이미지 전환 버튼과 스와이프로 이미지를 넘겨볼 수 있도록 한다. 이미지가 넘어가면
currentImage변수에 현재 저장된 이미지를 업데이트한다. - 스와이프 동작의 경우 좌 / 우로 스와이프하면 이미지를 넘기는 것이고, 위 / 아래로 스와이프하면 이미지 뷰어가 닫힌다.
- 모바일에서 스와이프 동작을 할 경우 기본 스크롤 동작이 작동되면 커스텀 동작이 씹히므로,
touch-action: none값을 통해 기본 스와이프 동작을 차단한다. 동시에 안정성을 추가로 확보하기 위해 이미지 뷰어가 열려있을 때body에pointer-events값을 부여해 이미지 뷰어에만 포인터 동작이 작동하도록 한다.
자세한 로직은 코드의 주석을 참고해보자.
이미지 뷰어 컴포넌트 전체 코드 (ImageViewer.svelte) :
<script lang="ts">
import type { ImageViewerStatus } from "$lib/types"; // 타입 임포트
import { imgViewerStatusStore } from "@routes/client-config.svelte"; // 이미지 스토어 임포트
import { faTimes, faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/svelte-fontawesome";
import { fade } from "svelte/transition"; // 뷰어를 열고 닫을 때 트랜지션
import { flip } from "svelte/animate"; // 이미지를 전환할 때 애니메이션
import { page } from "$app/state"; // SSR의 경우 현재 페이지 상태를 구독하여 추가 작업
const DRAG_THRESHOLD = 100; // 좌 / 우 스와이프 시 얼만큼 움직여야 전환되게 할지에 대한 임계값
const CLOSE_THRESHOLD = 200; // 상 / 하 스와이프 시 얼만큼 움직여야 뷰어를 닫을지에 대한 임계값
const AXIS_LOCK_THRESHOLD = 10; // 상하좌우 스와이프 시 축이 한 방향으로만 고정되게 하기 위한 최소 이동 거리
const FIXED_UUIDS = ['dummy-1', 'dummy-2']; // 최소한으로 필요한 이미지 개수가 3개인데 부족한 경우 이 값들로 임의로 UUID를 채운다.
let { imgViewerStatus = $bindable(), SSRImageView = null } : { imgViewerStatus?: ImageViewerStatus, SSRImageView: string | null } = $props();
let SSRsrc = $derived(SSRImageView && decodeURIComponent(SSRImageView));
// 닫기 버튼이 비 Javascript 환경에서도 작동할 수 있도록 하이퍼링크로 구현하기 위해 이미지 뷰어를 연 페이지의 URL을 가져온다.
let cancelHref = $derived.by(()=>{
const url = new URL(page.url);
url.searchParams.delete('imgview');
return url.searchParams.toString() ? `?${url.searchParams.toString()}` : url.pathname;
});
// 모든 이미지를 별도의 변수에 저장하되 이미지 개수가 모자랄 경우 3개로 맞추어 채운다.
let allImages = $derived.by(() => {
const needed = 3 - (imgViewerStatus?.images.length || 0);
const images = imgViewerStatus?.images || []
if (images.length && needed > 0) {
if (needed === 2) return [...images, ...new Array(2).fill(images[0]).map((img, index) => ({...img, uuid: FIXED_UUIDS[index]}))];
if (needed === 1) return [...images, ...images.map((img, index) => ({...img, uuid: FIXED_UUIDS[index]}))];
}
return images;
});
let currentUUID = $state('');
// 현재 이미지가 무엇인지 currentSrc 값을 통해 알아내서 적절히 저장한다.
let currentImage = $derived({
src: imgViewerStatus?.currentSrc,
alt: imgViewerStatus?.images.find(img => img.src === imgViewerStatus.currentSrc)?.alt || '',
uuid: currentUUID || (imgViewerStatus?.images.find(img => img.src === imgViewerStatus.currentSrc)?.uuid)
});
let descriptionVisible = $derived(true);
let timeoutId: NodeJS.Timeout | null = null;
let switchCanceled = $state(false);
let clicked = $state(false);
let imgElement: HTMLImageElement[] = $state([]);
let descriptionElement: HTMLDivElement | undefined = $state();
let paginationElement: HTMLDivElement | undefined = $state();
let imgDragInitialX = $state(0);
let imgDragCurrentX = $state(0);
let imgDragInitialY = $state(0);
let imgDragCurrentY = $state(0);
let imgXLock = $state(false);
let imgYLock = $state(false);
// 화면에 렌더링 될 이미지만 따로 배열로 뽑아내어 저장한다.
let imgSet = $derived.by(()=>{
if (allImages.length) {
const currentIndex = allImages.findIndex(img => img.uuid === currentImage.uuid);
if (currentIndex === -1) return [];
const images = allImages;
const length = images.length;
const result = [
images[(currentIndex - 1 + length) % length],
images[currentIndex],
images[(currentIndex + 1) % length]
];
return result;
} else {
return [];
}
});
// 이미지 전환이 취소되었을 때 상태를 변경한다. (switchCanceled 변수로 클래스 제어)
$effect(()=>{
let timeout: NodeJS.Timeout;
if (switchCanceled) {
timeout = setTimeout(() => {
switchCanceled = false;
}, 200);
}
return () => {
if (timeout) clearTimeout(timeout);
}
})
// 마우스 움직이면 UI가 표시되고 시간이 지나면 숨겨질 수 있도록 하는 함수
function pointerMove(event: PointerEvent) {
if (!imgViewerStatus?.isOpen) return;
descriptionVisible = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
if (descriptionElement && descriptionElement.contains(event.target as Node)) return;
if (paginationElement && paginationElement.contains(event.target as Node)) return;
timeoutId = setTimeout(() => {
descriptionVisible = false;
}, 500);
}
// 마우스에서 이미지 외 영역을 클릭하면 이미지 뷰어가 닫히도록 하는 함수
function pointerDown(event: PointerEvent) {
if (imgElement && !imgElement.some(img => img.contains(event.target as Node))) {
imgViewerStatusStore.update(status => ({ ...status, isOpen: false }));
}
}
// 전체 포인터 움직임을 제어해 스와이프 동작을 구현하는 함수
function imgPointerHandler(event: PointerEvent) {
pointerMove(event);
event.stopPropagation();
event.preventDefault();
// 이미지 위에서 일어나는 일이 아니라면 함수 실행 중지
if (!(imgElement && imgElement.some(img => img.contains(event.target as Node)))) return;
// pointerdown 시 각종 상태 초기화
if (event.type === 'pointerdown') {
clicked = true;
switchCanceled = false;
imgDragInitialX = event.clientX;
imgDragInitialY = event.clientY;
imgDragCurrentX = event.clientX;
imgDragCurrentY = event.clientY;
}
// pointerup 시 초기 상태와 비교하여 뷰어 닫기 또는 이미지 전환 동작을 수행하고 상태 초기화
if (event.type === 'pointerup') {
if (!clicked) return;
// 각 축 이동 거리 확인
const deltaX = imgDragCurrentX - imgDragInitialX;
const deltaY = imgDragCurrentY - imgDragInitialY;
// X 축이 잠겨있고 Y축으로 움직인 거리가 CLOSE_THRESHOLD 값보다 크면 뷰어 닫기 동작 수행
if (imgXLock && Math.abs(dragDistanceY) > CLOSE_THRESHOLD) {
imgViewerStatusStore.update(status => ({ ...status, isOpen: false }));
} else { // 그게 아니라면 이미지 전환 동작 수행
if (dragDistanceX > DRAG_THRESHOLD) {
imageSwitch('prev');
} else if (dragDistanceX < -DRAG_THRESHOLD) {
imageSwitch('next');
} else { // 어느 한쪽으로도 스와이프 거리가 짧다면 이미지 전환을 취소
switchCanceled = true;
}
}
// 이동 축 값 초기화
imgDragInitialX = 0;
imgDragInitialY = 0;
imgDragCurrentX = 0;
imgDragCurrentY = 0;
// 이벤트 동작 시 변경되었던 값들도 초기화
clicked = false;
imgXLock = false;
imgYLock = false;
}
// pointerdown과 pointermove와 동시에 이루어지고 있는 경우
if (event.type === 'pointermove' && clicked) {
// 축이 잠겨있는 경우 이동 거리를 최초 거리로 변경하여 축을 고정
if (imgXLock) imgDragCurrentX = imgDragInitialX;
if (imgYLock) imgDragCurrentY = imgDragInitialY;
// 고정되지 않은 축으로 움직일 경우에만 현재 포인터 위치를 변수에 저장
if (!imgXLock) imgDragCurrentX = event.clientX;
if (!imgYLock) imgDragCurrentY = event.clientY;
// 각 축으로 움직인 거리 측정
const deltaX = Math.abs(imgDragCurrentX - imgDragInitialX);
const deltaY = Math.abs(imgDragCurrentY - imgDragInitialY);
// 축이 어느 한쪽으로라도 잠겨있는 경우에는 이후 함수 실행 중지
if (imgXLock || imgYLock) return;
// 축이 고정되어 있지 않다면 AXIS_LOCK_THRESHOLD 값을 참조해 어느 한쪽으로 먼저 임계점에 도달하면 해당 축의 교차 축을 잠금
if (deltaX > AXIS_LOCK_THRESHOLD) {
imgYLock = true;
} else if (deltaY > AXIS_LOCK_THRESHOLD) {
imgXLock = true;
}
}
}
// 이미지 전환 함수
function imageSwitch (direction: 'next' | 'prev') {
// 현재 이미지를 uuid 값을 통해 확인
const currentIndex = allImages.findIndex(img => img.uuid === currentImage.uuid);
if (currentIndex === -1 || currentIndex === undefined) return;
// 나머지 연산자를 통해 이미지가 순환되도록
const targetIndex = direction === 'next'
? (currentIndex + 1) % (allImages.length || 1)
: (currentIndex - 1 + (allImages.length || 1)) % (allImages.length || 1);
const targetImage = allImages[targetIndex];
currentUUID = targetImage.uuid; // 현재 UUID를 저장해 현재 이미지의 UUID 값을 재갱신 (채워진 이미지 식별용)
// 현재 이미지가 제대로 있는 경우 스토어의 currentSrc 값을 최신화
if (targetImage) {
imgViewerStatusStore.update(status => ({ ...status, currentSrc: targetImage.src }));
}
}
</script>
<svelte:window onpointermove={imgPointerHandler} onpointerdown={imgPointerHandler} onpointerup={imgPointerHandler} onpointerout={(event)=>{
// 포인터가 화면 밖으로 나갔을 때 상태 초기화
event.stopPropagation();
switchCanceled = true;
clicked = false;
imgDragInitialX = 0;
imgDragInitialY = 0;
imgDragCurrentX = 0;
imgDragCurrentY = 0;
}} />
<div class={["img-viewer", descriptionVisible && "visible"]} transition:fade={{duration: 100}}>
<div class={["img-container", switchCanceled && "switch-canceled", SSRsrc && "single"]} onpointerdown={pointerDown}>
{#each imgSet as image, index (image.uuid)}
<!-- 스와이프 한 거리만큼 이미지의 위치 값을 변경해 따라오게 만든다. animate:flip을 통해 이미지가 순환될 때 자연스럽게 연결되도록 한다. -->
<img src={image.src} alt={image.alt} bind:this={imgElement[index]} animate:flip={{duration: 200}} style="--x: {imgDragCurrentX - imgDragInitialX}; --y: {Math.floor(200 * Math.tanh((imgDragCurrentY - imgDragInitialY) / 200))}"/>
{/each}
{#if !currentImage.src && !SSRsrc}
<div class="placeholder">이미지를 불러올 수 없습니다.</div>
{/if}
{#if SSRsrc}
<img src={SSRsrc} alt={currentImage.alt} />
{/if}
</div>
<div class={["img-description", !currentImage.alt && "no-alt"]} bind:this={descriptionElement}>{currentImage.alt}</div>
<a class="plain close" onclick={(e) => {e.preventDefault(); imgViewerStatusStore.update(status => ({ ...status, isOpen: false }))}} title="닫기" href={cancelHref}><FontAwesomeIcon icon={faTimes} /></a>
{#if !SSRsrc}
<div class="img-pagination" bind:this={paginationElement}>
<div>
<button class="plain" onclick={() => imageSwitch('prev')} title="이전 이미지"><FontAwesomeIcon icon={faChevronLeft} /></button>
</div>
<div>
<button class="plain" onclick={() => imageSwitch('next')} title="다음 이미지"><FontAwesomeIcon icon={faChevronRight} /></button>
</div>
</div>
{/if}
</div>스타일:
<style>
.img-viewer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none;
pointer-events: all;
}
.img-container {
width: 300%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 0;
}
.img-container.single {
width: 100%;
}
.img-container img {
width: 100%;
min-width: 100%;
max-height: 100%;
object-fit: contain;
flex: 1;
position: relative;
transition: opacity 0.2s;
transform: translateX(calc(var(--x) * 1px)) translateY(calc(var(--y) * 1px));
}
.img-container :is(img:nth-child(1), img:nth-last-child(1)) {
opacity: 0;
}
.img-container img:not(:nth-child(1)):not(:nth-last-child(1)) {
opacity: 1;
}
.img-container.single img {
opacity: 1 !important;
}
.img-container.switch-canceled img {
transition: opacity 0.2s, transform 0.2s;
}
.img-description {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
color: white;
background: linear-gradient(0deg, #000e, transparent);
padding: 3rem;
z-index: 1;
width: 100%;
text-align: center;
font-size: 1.5em;
opacity: 0;
transition: opacity 1s;
}
.img-description.no-alt {
display: none;
}
.visible :is(.img-description, button, a) {
opacity: 1;
transition: opacity 0.1s;
}
.img-pagination {
position: absolute;
top: 50%;
left: 0;
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 0.5rem;
z-index: 2;
pointer-events: none;
}
a.close {
position: absolute;
top: 1em;
right: 1em;
}
button.plain, a.plain {
border: none;
padding: 0.5em;
cursor: pointer;
z-index: 2;
font-size: 1.5em;
aspect-ratio: 1/1;
opacity: 0;
transition: opacity 1s;
color: #000;
background-color: #fffa;
border-radius: 50%;
backdrop-filter: blur(5px);
box-shadow: 0 2px 10px #0003;
pointer-events: all;
}
.placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 1.5em;
}
:global(body:has(.img-viewer) > div:has(main)) {
pointer-events: none;
}
</style>결론
다른 React나 이런 것들과 다르게 상태 변수를 쉽게 선언하고 또 DOM에 연결하는 것이 매우 편리하게 가능하기 때문에 이런 작업에 있어서도 구조가 지나치게 복잡해지지 않고 구현되는 것이 Svelte의 장점인 것 같다.