본문 갤러리 이미지 계산 분리
This commit is contained in:
@@ -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))
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 비율 저장 키를 만든다.
|
||||
|
||||
@@ -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 본문 블록 파싱 단위 회귀 테스트 |
|
||||
|
||||
@@ -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/
|
||||
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
|
||||
- **파일명을 캡션으로 사용** 토글: 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 단독 줄)
|
||||
|
||||
@@ -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`로 분리.
|
||||
|
||||
56
lib/content-gallery-images.js
Normal file
56
lib/content-gallery-images.js
Normal 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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.55",
|
||||
"version": "1.11.56",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
76
tests/content-gallery-images.test.js
Normal file
76
tests/content-gallery-images.test.js
Normal 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' }] }
|
||||
])
|
||||
})
|
||||
Reference in New Issue
Block a user