본문 갤러리 이미지 계산 분리

This commit is contained in:
2026-07-24 15:40:40 +09:00
parent 544d4cb1c1
commit 24dc6b8af0
8 changed files with 148 additions and 52 deletions

View File

@@ -41,6 +41,7 @@ import {
normalizeQuoteEditableValue
} from '../../lib/markdown-quote.js'
import { getInArticleAdAfterBlockIds } from '../../lib/content-in-article-ad.js'
import { getContentLightboxImages, getGalleryRows } from '../../lib/content-gallery-images.js'
import { parseMarkdownBlocks } from '../../lib/markdown-block-parser.js'
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
@@ -203,52 +204,7 @@ 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
})
/**
* 갤러리 이미지를 행 단위로 최대 3개씩 나눈다.
* @param {Array<Object>} images - 갤러리 이미지 목록
* @returns {Array<{ startIndex: number, images: Array<Object> }>} 행 목록
*/
const getGalleryRows = (images) => {
const rows = []
for (let index = 0; index < images.length; index += 3) {
rows.push({
startIndex: index,
images: images.slice(index, index + 3)
})
}
return rows
}
const contentLightboxImages = computed(() => getContentLightboxImages(blocks.value))
/**
* 갤러리 이미지 비율 저장 키를 만든다.

View File

@@ -33,6 +33,7 @@
|------|------|
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) |
| lib/content-in-article-ad.js | 공개 Markdown 본문 인아티클 광고 삽입 후보·목표 위치 계산 |
| lib/content-gallery-images.js | 공개 Markdown 본문 단일 이미지·갤러리 라이트박스 목록 생성과 갤러리 행 분할 계산 |
| lib/content-image-urls.js | 공개 Markdown 본문 이미지 표시 URL·라이트박스 URL·srcset·갤러리 sizes 계산 |
| lib/admin-social-settings-section.js | 관리자 설정 SNS 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
| lib/admin-post-settings-section.js | 관리자 설정 POST 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
@@ -111,6 +112,7 @@
| docker-compose.test.yml | 실행마다 생성·폐기하는 PostgreSQL 16 테스트 DB·볼륨·네트워크 |
| tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 |
| tests/content-in-article-ad.test.js | 공개 본문 인아티클 광고 삽입 위치 계산 단위 회귀 테스트 |
| tests/content-gallery-images.test.js | 공개 본문 라이트박스 이미지 목록과 갤러리 행 분할 단위 회귀 테스트 |
| tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 |
| tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 |
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |

View File

@@ -127,7 +127,7 @@
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다.
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록과 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다.
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
@@ -302,10 +302,10 @@ components/content/
- 캡션(표시용): `![](url "캡션")` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
- **파일명을 캡션으로 사용** 토글: URL 파일명을 캡션으로 저장·표시(`![](url "파일명")`). 레거시 `![파일명](url)`도 동일하게 해석
- 와이드/풀: `![alt](url){width=wide|full}` 또는 캡션·width 조합
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다.
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 `lib/content-gallery-images.js` 기준 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다.
- 이미지 갤러리
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음)
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음). 행 분할과 라이트박스 목록 생성은 `lib/content-gallery-images.js`에서 수행한다.
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
- 비디오·오디오·파일 카드
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.56
- 공개 Markdown 렌더러의 라이트박스 이미지 목록과 갤러리 행 분할 계산을 `lib/content-gallery-images.js`로 분리.
- 단일 이미지·갤러리 이미지 `lightboxKey` 생성과 갤러리 3열 행 분할 단위 테스트를 추가.
- `ContentMarkdownRenderer`는 라이트박스 상태와 갤러리 이벤트 연결에 집중하도록 정리.
## v1.11.55
- 공개 Markdown 렌더러의 본문 이미지 표시·라이트박스·srcset·갤러리 sizes 계산을 `lib/content-image-urls.js`로 분리.

View File

@@ -0,0 +1,56 @@
/**
* 공개 본문 라이트박스 이미지 목록을 만든다.
* @param {Array<Object>} blocks - Markdown 렌더링 블록 목록
* @returns {Array<Object>} 라이트박스 이미지 목록
*/
export const getContentLightboxImages = (blocks) => {
const images = []
for (const block of blocks || []) {
if (block?.type === 'image' && block.url) {
images.push({
...block,
lightboxKey: block.id
})
continue
}
if (block?.type !== 'gallery' || !Array.isArray(block.images)) {
continue
}
block.images.forEach((image, imageIndex) => {
if (!image?.url) {
return
}
images.push({
...image,
lightboxKey: `${block.id}:${imageIndex}`
})
})
}
return images
}
/**
* 갤러리 이미지를 행 단위로 나눈다.
* @param {Array<Object>} images - 갤러리 이미지 목록
* @param {number} [rowSize=3] - 행당 이미지 개수
* @returns {Array<{ startIndex: number, images: Array<Object> }>} 행 목록
*/
export const getGalleryRows = (images, rowSize = 3) => {
const safeImages = Array.isArray(images) ? images : []
const safeRowSize = Number.isInteger(rowSize) && rowSize > 0 ? rowSize : 3
const rows = []
for (let index = 0; index < safeImages.length; index += safeRowSize) {
rows.push({
startIndex: index,
images: safeImages.slice(index, index + safeRowSize)
})
}
return rows
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.55",
"version": "1.11.56",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.55",
"version": "1.11.56",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.55",
"version": "1.11.56",
"private": true,
"type": "module",
"imports": {

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getContentLightboxImages,
getGalleryRows
} from '../lib/content-gallery-images.js'
test('본문 라이트박스 목록은 단일 이미지와 갤러리 이미지를 원문 순서대로 펼친다', () => {
const images = getContentLightboxImages([
{
id: 'block-1',
type: 'image',
url: '/uploads/posts/2026/07/a.jpg',
alt: '단일'
},
{
id: 'block-2',
type: 'paragraph',
text: '본문'
},
{
id: 'block-3',
type: 'gallery',
images: [
{ url: '/uploads/posts/2026/07/b.jpg', alt: '갤러리 1' },
{ url: '', alt: '빈 이미지' },
{ url: '/uploads/posts/2026/07/c.jpg', alt: '갤러리 3' }
]
}
])
assert.deepEqual(
images.map((image) => [image.url, image.lightboxKey]),
[
['/uploads/posts/2026/07/a.jpg', 'block-1'],
['/uploads/posts/2026/07/b.jpg', 'block-3:0'],
['/uploads/posts/2026/07/c.jpg', 'block-3:2']
]
)
})
test('본문 라이트박스 목록은 이미지 URL이 없는 블록을 제외한다', () => {
assert.deepEqual(getContentLightboxImages([
{ id: 'block-1', type: 'image', url: '' },
{ id: 'block-2', type: 'gallery', images: null },
{ id: 'block-3', type: 'gallery', images: [{ alt: 'url 없음' }] }
]), [])
})
test('갤러리 행은 기본 3개 단위로 나눈다', () => {
const rows = getGalleryRows([
{ url: '1.jpg' },
{ url: '2.jpg' },
{ url: '3.jpg' },
{ url: '4.jpg' },
{ url: '5.jpg' },
{ url: '6.jpg' },
{ url: '7.jpg' }
])
assert.deepEqual(
rows.map((row) => [row.startIndex, row.images.map((image) => image.url)]),
[
[0, ['1.jpg', '2.jpg', '3.jpg']],
[3, ['4.jpg', '5.jpg', '6.jpg']],
[6, ['7.jpg']]
]
)
})
test('갤러리 행 분할은 잘못된 입력을 빈 목록으로 처리한다', () => {
assert.deepEqual(getGalleryRows(null), [])
assert.deepEqual(getGalleryRows([{ url: '1.jpg' }, { url: '2.jpg' }], 0), [
{ startIndex: 0, images: [{ url: '1.jpg' }, { url: '2.jpg' }] }
])
})