diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 9983262..b4f869a 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -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} images - 갤러리 이미지 목록 - * @returns {Array<{ startIndex: number, images: Array }>} 행 목록 - */ -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)) /** * 갤러리 이미지 비율 저장 키를 만든다. diff --git a/docs/map.md b/docs/map.md index a8b03b5..a613548 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 본문 블록 파싱 단위 회귀 테스트 | diff --git a/docs/spec.md b/docs/spec.md index e71a7fc..628616c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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 단독 줄) diff --git a/docs/update.md b/docs/update.md index 1696e59..a83aaac 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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`로 분리. diff --git a/lib/content-gallery-images.js b/lib/content-gallery-images.js new file mode 100644 index 0000000..f2777ac --- /dev/null +++ b/lib/content-gallery-images.js @@ -0,0 +1,56 @@ +/** + * 공개 본문 라이트박스 이미지 목록을 만든다. + * @param {Array} blocks - Markdown 렌더링 블록 목록 + * @returns {Array} 라이트박스 이미지 목록 + */ +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} images - 갤러리 이미지 목록 + * @param {number} [rowSize=3] - 행당 이미지 개수 + * @returns {Array<{ startIndex: number, images: Array }>} 행 목록 + */ +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 +} diff --git a/package-lock.json b/package-lock.json index 38e3e12..2a979f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 259c605..0bf1948 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.55", + "version": "1.11.56", "private": true, "type": "module", "imports": { diff --git a/tests/content-gallery-images.test.js b/tests/content-gallery-images.test.js new file mode 100644 index 0000000..0ba13dc --- /dev/null +++ b/tests/content-gallery-images.test.js @@ -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' }] } + ]) +})