본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 16:19:45 +09:00
parent 0eca6b288e
commit c686b1c848
20 changed files with 812 additions and 143 deletions

View File

@@ -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

View File

@@ -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)]"