본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
isImageUrl,
|
||||
parseImageMarkdownLine
|
||||
} from '../../lib/markdown-image.js'
|
||||
import { getPostBodyThumbnailUrl } from '../../lib/post-card-thumbnail-url.js'
|
||||
import { readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
|
||||
import {
|
||||
applyLiveSelectionDelete,
|
||||
@@ -989,6 +990,42 @@ const tailInsertBeforeLine = computed(() => {
|
||||
})
|
||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||
|
||||
const contentLightboxImages = computed(() => {
|
||||
const images = []
|
||||
|
||||
blocks.value.forEach((block) => {
|
||||
if (block.type === 'image' && block.url) {
|
||||
images.push({
|
||||
...block,
|
||||
lightboxKey: block.id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (block.type === 'gallery' && Array.isArray(block.images)) {
|
||||
block.images.forEach((image, imageIndex) => {
|
||||
if (!image?.url) {
|
||||
return
|
||||
}
|
||||
|
||||
images.push({
|
||||
...image,
|
||||
lightboxKey: `${block.id}:${imageIndex}`
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return images
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 본문 표시용 이미지 URL을 조회한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @returns {string} 표시용 이미지 URL
|
||||
*/
|
||||
const getContentDisplayImageUrl = (imageUrl) => getPostBodyThumbnailUrl(imageUrl) || imageUrl || ''
|
||||
|
||||
/**
|
||||
* 갤러리 이미지를 행 단위로 최대 3개씩 나눈다.
|
||||
* @param {Array<Object>} images - 갤러리 이미지 목록
|
||||
@@ -1037,6 +1074,22 @@ const onGalleryImageLoad = (event, blockId, imageIndex, url) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 표시용 이미지 로드 실패 시 원본 이미지로 되돌린다.
|
||||
* @param {Event} event - 이미지 로드 이벤트
|
||||
* @param {string} fallbackUrl - 원본 이미지 URL
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryImageError = (event, fallbackUrl) => {
|
||||
const image = event.target
|
||||
|
||||
if (!(image instanceof HTMLImageElement) || !fallbackUrl || image.src.endsWith(fallbackUrl)) {
|
||||
return
|
||||
}
|
||||
|
||||
image.src = fallbackUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 셀의 flex 비율 스타일을 만든다.
|
||||
* @param {Object} block - 갤러리 블록
|
||||
@@ -2457,6 +2510,22 @@ const openLightbox = (images, index) => {
|
||||
activeLightboxIndex.value = index
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 전체 이미지 목록에서 지정 이미지를 라이트박스로 연다.
|
||||
* @param {string} lightboxKey - 이미지 식별 키
|
||||
* @returns {void}
|
||||
*/
|
||||
const openContentLightbox = (lightboxKey) => {
|
||||
const images = contentLightboxImages.value
|
||||
const index = images.findIndex((image) => image.lightboxKey === lightboxKey)
|
||||
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
openLightbox(images, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스를 닫는다
|
||||
* @returns {void}
|
||||
@@ -3131,18 +3200,24 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
<ProseImage
|
||||
:src="block.url"
|
||||
:src="getContentDisplayImageUrl(block.url)"
|
||||
:fallback-src="block.url"
|
||||
:alt="getImageAltAttribute(block)"
|
||||
:caption="getImageDisplayCaption(block)"
|
||||
:variant="block.width"
|
||||
expandable
|
||||
@activate="openContentLightbox(block.id)"
|
||||
/>
|
||||
</section>
|
||||
<ProseImage
|
||||
v-else-if="block.type === 'image'"
|
||||
:src="block.url"
|
||||
:src="getContentDisplayImageUrl(block.url)"
|
||||
:fallback-src="block.url"
|
||||
:alt="getImageAltAttribute(block)"
|
||||
:caption="getImageDisplayCaption(block)"
|
||||
:variant="block.width"
|
||||
expandable
|
||||
@activate="openContentLightbox(block.id)"
|
||||
/>
|
||||
<ContentMarkdownCalloutEditor
|
||||
v-else-if="block.type === 'callout' && interactive"
|
||||
@@ -3334,13 +3409,17 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="content-markdown-renderer__gallery-button w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||
type="button"
|
||||
@click="openLightbox(block.images, row.startIndex + rowImageIndex)"
|
||||
aria-label="이미지 크게 보기"
|
||||
@click="openContentLightbox(`${block.id}:${row.startIndex + rowImageIndex}`)"
|
||||
>
|
||||
<img
|
||||
class="content-markdown-renderer__gallery-image h-full w-full object-cover transition-transform hover:scale-[1.02]"
|
||||
:src="image.url"
|
||||
:src="getContentDisplayImageUrl(image.url)"
|
||||
:alt="getImageAltAttribute(image)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load="onGalleryImageLoad($event, block.id, row.startIndex + rowImageIndex, image.url)"
|
||||
@error="onGalleryImageError($event, image.url)"
|
||||
>
|
||||
</button>
|
||||
<figcaption
|
||||
|
||||
@@ -6,6 +6,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fallbackSrc: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
@@ -18,15 +22,22 @@ const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'regular'
|
||||
},
|
||||
expandable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const loadFailed = ref(false)
|
||||
const emit = defineEmits(['activate'])
|
||||
|
||||
const hasRenderableSrc = computed(() => String(props.src || '').trim().length > 0)
|
||||
const loadFailed = ref(false)
|
||||
const currentSrc = ref(props.src)
|
||||
|
||||
const hasRenderableSrc = computed(() => String(currentSrc.value || '').trim().length > 0)
|
||||
|
||||
const errorLabel = computed(() => {
|
||||
const trimmed = String(props.src || '').trim()
|
||||
const trimmed = String(props.fallbackSrc || props.src || '').trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return '이미지 URL이 비어 있습니다'
|
||||
@@ -38,6 +49,7 @@ const errorLabel = computed(() => {
|
||||
})
|
||||
|
||||
watch(() => props.src, () => {
|
||||
currentSrc.value = props.src
|
||||
loadFailed.value = false
|
||||
})
|
||||
|
||||
@@ -46,6 +58,11 @@ watch(() => props.src, () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageError = () => {
|
||||
if (props.fallbackSrc && currentSrc.value !== props.fallbackSrc) {
|
||||
currentSrc.value = props.fallbackSrc
|
||||
return
|
||||
}
|
||||
|
||||
loadFailed.value = true
|
||||
}
|
||||
|
||||
@@ -56,6 +73,18 @@ const onImageError = () => {
|
||||
const onImageLoad = () => {
|
||||
loadFailed.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 확대 요청을 전달한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const activateImage = () => {
|
||||
if (!props.expandable || !hasRenderableSrc.value || loadFailed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('activate')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,18 +95,25 @@ const onImageLoad = () => {
|
||||
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="prose-image__frame overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||
<component
|
||||
:is="expandable && hasRenderableSrc && !loadFailed ? 'button' : 'div'"
|
||||
class="prose-image__frame block w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-0 text-left text-inherit"
|
||||
:class="{
|
||||
'prose-image__frame--empty': !hasRenderableSrc || loadFailed,
|
||||
'prose-image__frame--broken': loadFailed
|
||||
'prose-image__frame--broken': loadFailed,
|
||||
'prose-image__frame--expandable cursor-zoom-in': expandable && hasRenderableSrc && !loadFailed
|
||||
}"
|
||||
:type="expandable && hasRenderableSrc && !loadFailed ? 'button' : undefined"
|
||||
:aria-label="expandable && hasRenderableSrc && !loadFailed ? '이미지 크게 보기' : undefined"
|
||||
@click="activateImage"
|
||||
>
|
||||
<img
|
||||
v-if="hasRenderableSrc && !loadFailed"
|
||||
class="prose-image__media w-full object-cover"
|
||||
:src="src"
|
||||
:src="currentSrc"
|
||||
:alt="alt"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
>
|
||||
@@ -92,7 +128,7 @@ const onImageLoad = () => {
|
||||
{{ errorLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
<figcaption
|
||||
v-if="caption"
|
||||
class="prose-image__caption mt-1.5 text-center text-sm text-[var(--site-muted)]"
|
||||
|
||||
145
components/site/SitePagination.vue
Normal file
145
components/site/SitePagination.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '페이지'
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const normalizedTotalPages = computed(() => Math.max(1, Number(props.totalPages) || 1))
|
||||
const currentPage = computed(() => Math.max(1, Math.min(Number(props.page) || 1, normalizedTotalPages.value)))
|
||||
const previousPage = computed(() => Math.max(1, currentPage.value - 1))
|
||||
const nextPage = computed(() => Math.min(normalizedTotalPages.value, currentPage.value + 1))
|
||||
const canGoPrevious = computed(() => currentPage.value > 1)
|
||||
const canGoNext = computed(() => currentPage.value < normalizedTotalPages.value)
|
||||
|
||||
/**
|
||||
* 연속 페이지 번호를 만든다.
|
||||
* @param {number} start - 시작 페이지
|
||||
* @param {number} end - 끝 페이지
|
||||
* @returns {Array<number>} 페이지 번호 목록
|
||||
*/
|
||||
const range = (start, end) => {
|
||||
const pages = []
|
||||
|
||||
for (let page = start; page <= end; page += 1) {
|
||||
pages.push(page)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
const pageItems = computed(() => {
|
||||
const total = normalizedTotalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 8) {
|
||||
return range(1, total).map((page) => ({ type: 'page', page }))
|
||||
}
|
||||
|
||||
const pages = new Set([1, total])
|
||||
|
||||
if (current <= 5) {
|
||||
range(1, Math.min(total - 1, Math.max(4, current + 3))).forEach((page) => pages.add(page))
|
||||
} else if (current >= total - 4) {
|
||||
range(Math.max(2, total - 7), total).forEach((page) => pages.add(page))
|
||||
} else {
|
||||
range(Math.max(2, current - 3), Math.min(total - 1, current + 3)).forEach((page) => pages.add(page))
|
||||
}
|
||||
|
||||
const sortedPages = [...pages].sort((left, right) => left - right)
|
||||
const items = []
|
||||
|
||||
sortedPages.forEach((page, index) => {
|
||||
const previous = sortedPages[index - 1]
|
||||
|
||||
if (previous && page - previous > 1) {
|
||||
items.push({
|
||||
type: 'ellipsis',
|
||||
key: `ellipsis-${previous}-${page}`
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'page',
|
||||
page
|
||||
})
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
/**
|
||||
* 페이지 링크 대상을 만든다.
|
||||
* @param {number} page - 페이지 번호
|
||||
* @returns {Object} Nuxt 링크 대상
|
||||
*/
|
||||
const getPageLinkTo = (page) => ({
|
||||
path: props.basePath,
|
||||
query: page > 1 ? { page } : {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="site-pagination site-section" :aria-label="label">
|
||||
<div class="site-pagination__body site-section-body mx-auto flex max-w-[720px] items-center justify-start gap-3 overflow-x-auto text-sm sm:justify-center sm:gap-4">
|
||||
<NuxtLink
|
||||
class="site-pagination__arrow inline-flex size-10 items-center justify-center rounded-full border border-[var(--site-line)] text-[var(--site-muted)] transition hover:border-[var(--site-soft)] hover:text-[var(--site-text)]"
|
||||
:class="!canGoPrevious ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPageLinkTo(previousPage)"
|
||||
:aria-disabled="!canGoPrevious"
|
||||
:aria-label="`${label} 이전으로 이동`"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
|
||||
<ol class="site-pagination__pages flex min-w-max items-center justify-center gap-1 sm:gap-2">
|
||||
<li v-for="item in pageItems" :key="item.type === 'page' ? `page-${item.page}` : item.key">
|
||||
<span
|
||||
v-if="item.type === 'ellipsis'"
|
||||
class="site-pagination__ellipsis inline-flex min-w-9 items-center justify-center px-1 text-base font-semibold text-[var(--site-muted)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-else
|
||||
class="site-pagination__page inline-flex min-w-9 items-center justify-center rounded-full px-2 py-2 text-base font-semibold text-[var(--site-muted)] transition hover:text-[var(--site-text)]"
|
||||
:class="item.page === currentPage ? 'site-pagination__page--current pointer-events-none text-[var(--site-text)]' : ''"
|
||||
:to="getPageLinkTo(item.page)"
|
||||
:aria-current="item.page === currentPage ? 'page' : undefined"
|
||||
:aria-label="`${item.page}페이지로 이동`"
|
||||
>
|
||||
{{ item.page }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<NuxtLink
|
||||
class="site-pagination__arrow inline-flex size-10 items-center justify-center rounded-full border border-[var(--site-line)] text-[var(--site-muted)] transition hover:border-[var(--site-soft)] hover:text-[var(--site-text)]"
|
||||
:class="!canGoNext ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPageLinkTo(nextPage)"
|
||||
:aria-disabled="!canGoNext"
|
||||
:aria-label="`${label} 다음으로 이동`"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
Reference in New Issue
Block a user