공개 본문 라이트박스 상태 분리

This commit is contained in:
2026-07-27 12:22:18 +09:00
parent bf48af7bd3
commit b15282f41f
10 changed files with 348 additions and 105 deletions

View File

@@ -44,6 +44,7 @@ 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 { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
import ProseHtml from './ProseHtml.vue'
@@ -100,8 +101,6 @@ const emit = defineEmits([
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
/** @type {string} 본문 리스트 마커 색상 */
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
const galleryImageAspectRatios = ref({})
/** @type {import('vue').Ref<{ kind: 'image'|'gallery', sourceLine?: number, startLine?: number, endLine?: number, blockId: string, imageIndex?: number, image?: Object }|null>} */
@@ -124,6 +123,15 @@ const rawEditingSourceLines = ref(new Set())
/** @type {number} 문단 분리 연속 호출 방지 */
let lastParagraphSplitAt = 0
const {
activeLightboxImages,
activeLightboxImage,
openContentLightbox: openContentLightboxFromImages,
closeLightbox,
showPreviousImage,
showNextImage
} = useContentMarkdownLightbox()
/**
* 빈 줄 공백 블록 높이를 반환한다.
* @param {Object} block - 렌더링 블록
@@ -202,8 +210,6 @@ const tailInsertBeforeLine = computed(() => {
return (props.content || '').split('\n').length
})
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
const contentLightboxImages = computed(() => getContentLightboxImages(blocks.value))
/**
@@ -1629,58 +1635,13 @@ const onMergeWithPreviousLine = (lineIndex, payload) => {
emit('merge-with-previous-line', { lineIndex, mergedLine })
}
/**
* 라이트박스를 연다
* @param {Array<Object>} images - 이미지 목록
* @param {number} index - 시작 인덱스
* @returns {void}
*/
const openLightbox = (images, index) => {
activeLightboxImages.value = images
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}
*/
const closeLightbox = () => {
activeLightboxImages.value = []
activeLightboxIndex.value = 0
}
/**
* 라이트박스 이전 이미지로 이동
* @returns {void}
*/
const showPreviousImage = () => {
activeLightboxIndex.value = activeLightboxIndex.value === 0
? activeLightboxImages.value.length - 1
: activeLightboxIndex.value - 1
}
/**
* 라이트박스 다음 이미지로 이동
* @returns {void}
*/
const showNextImage = () => {
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
openContentLightboxFromImages(contentLightboxImages.value, lightboxKey)
}
/**
@@ -2110,55 +2071,6 @@ const onGalleryDrop = (event, block, targetIndex) => {
clearLiveImageDragUi()
}
/**
* 라이트박스 키보드 조작(Esc 닫기, 좌우 이전·다음)
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleLightboxKeydown = (event) => {
if (!activeLightboxImage.value) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeLightbox()
return
}
if (activeLightboxImages.value.length <= 1) {
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
showPreviousImage()
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
showNextImage()
}
}
watch(activeLightboxImage, (image) => {
if (!import.meta.client) {
return
}
if (image) {
window.addEventListener('keydown', handleLightboxKeydown)
} else {
window.removeEventListener('keydown', handleLightboxKeydown)
}
})
onBeforeUnmount(() => {
if (import.meta.client) {
window.removeEventListener('keydown', handleLightboxKeydown)
}
})
</script>
<template>

View File

@@ -0,0 +1,191 @@
import { computed, getCurrentInstance, onBeforeUnmount, ref } from 'vue'
/**
* 공개 Markdown 렌더러의 이미지 라이트박스 상태를 관리한다.
* @param {Object} [options] - 설정
* @param {() => (Window|null)} [options.getKeyboardTarget] - 키보드 이벤트 대상 반환 함수
* @returns {{
* activeLightboxImages: import('vue').Ref<Array<Object>>,
* activeLightboxIndex: import('vue').Ref<number>,
* activeLightboxImage: import('vue').ComputedRef<Object|undefined>,
* openLightbox: (images: Array<Object>, index: number) => void,
* openContentLightbox: (images: Array<Object>, lightboxKey: string) => void,
* closeLightbox: () => void,
* showPreviousImage: () => void,
* showNextImage: () => void,
* handleLightboxKeydown: (event: KeyboardEvent) => void
* }} 라이트박스 상태와 액션
*/
export const useContentMarkdownLightbox = (options = {}) => {
const getKeyboardTarget = options.getKeyboardTarget || (() => {
if (!import.meta.client) {
return null
}
return window
})
const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
let keyboardListenerAttached = false
/**
* 전역 키보드 이벤트를 연결한다.
* @returns {void}
*/
const attachKeyboardListener = () => {
if (keyboardListenerAttached) {
return
}
const target = getKeyboardTarget()
if (!target) {
return
}
target.addEventListener('keydown', handleLightboxKeydown)
keyboardListenerAttached = true
}
/**
* 전역 키보드 이벤트를 해제한다.
* @returns {void}
*/
const detachKeyboardListener = () => {
if (!keyboardListenerAttached) {
return
}
const target = getKeyboardTarget()
if (target) {
target.removeEventListener('keydown', handleLightboxKeydown)
}
keyboardListenerAttached = false
}
/**
* 라이트박스를 연다.
* @param {Array<Object>} images - 이미지 목록
* @param {number} index - 시작 인덱스
* @returns {void}
*/
const openLightbox = (images, index) => {
const nextImages = Array.isArray(images) ? images : []
if (!nextImages.length) {
activeLightboxImages.value = []
activeLightboxIndex.value = 0
detachKeyboardListener()
return
}
activeLightboxImages.value = nextImages
activeLightboxIndex.value = Math.max(0, Math.min(Number(index) || 0, nextImages.length - 1))
attachKeyboardListener()
}
/**
* 본문 전체 이미지 목록에서 지정 이미지를 라이트박스로 연다.
* @param {Array<Object>} images - 본문 라이트박스 이미지 목록
* @param {string} lightboxKey - 이미지 식별 키
* @returns {void}
*/
const openContentLightbox = (images, lightboxKey) => {
const targetImages = Array.isArray(images) ? images : []
const index = targetImages.findIndex((image) => image.lightboxKey === lightboxKey)
if (index < 0) {
return
}
openLightbox(targetImages, index)
}
/**
* 라이트박스를 닫는다.
* @returns {void}
*/
const closeLightbox = () => {
activeLightboxImages.value = []
activeLightboxIndex.value = 0
detachKeyboardListener()
}
/**
* 라이트박스 이전 이미지로 이동한다.
* @returns {void}
*/
const showPreviousImage = () => {
if (activeLightboxImages.value.length <= 1) {
return
}
activeLightboxIndex.value = activeLightboxIndex.value === 0
? activeLightboxImages.value.length - 1
: activeLightboxIndex.value - 1
}
/**
* 라이트박스 다음 이미지로 이동한다.
* @returns {void}
*/
const showNextImage = () => {
if (activeLightboxImages.value.length <= 1) {
return
}
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
}
/**
* 라이트박스 키보드 조작을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
function handleLightboxKeydown (event) {
if (!activeLightboxImage.value) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeLightbox()
return
}
if (activeLightboxImages.value.length <= 1) {
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
showPreviousImage()
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
showNextImage()
}
}
if (getCurrentInstance()) {
onBeforeUnmount(detachKeyboardListener)
}
return {
activeLightboxImages,
activeLightboxIndex,
activeLightboxImage,
openLightbox,
openContentLightbox,
closeLightbox,
showPreviousImage,
showNextImage,
handleLightboxKeydown
}
}

View File

@@ -1,3 +1,7 @@
## 2026-07-27 v1.11.95 — 공개 Markdown 라이트박스 상태를 분리한다
공개 Markdown 렌더러는 본문 블록 표시와 관리자 라이브 편집 중개가 함께 있어 작은 전역 이벤트 상태가 파일 안에 남아도 추적 비용이 커진다. 단일 이미지·갤러리 라이트박스의 열린 이미지 목록, 현재 인덱스, 좌우 이동, ESC 닫기, 키보드 이벤트 연결/해제를 `useContentMarkdownLightbox`로 분리해 렌더러는 클릭한 이미지 키를 전달하고 UI를 표시하는 역할에 집중한다.
## 2026-07-27 v1.11.94 — Markdown 에디터의 입력 상태와 단축키 판별을 분리한다
관리자 Markdown 에디터는 미디어 선택, 슬래시 명령, 히스토리, 블록 패널이 한 파일에서 맞물려 동작하므로, 작은 조건문을 계속 파일 안에 남기면 회귀 원인을 찾기 어려워진다. 전역 단축키 판별은 순수 helper로, 슬래시 메뉴 refs·computed·억제 줄·키보드 탐색 타이머는 composable로 분리해 에디터 컴포넌트가 실제 편집 흐름 연결에 더 집중하도록 정리한다.

View File

@@ -26,6 +26,7 @@
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·억제 줄·키보드 탐색 타이머 관리 |
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
@@ -129,6 +130,7 @@
| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·모달 종류·제외 줄 Set 갱신·하이라이트 인덱스 보정·방향키 순환·키보드 동작 분류·포커스 대상·textarea 선택 위치·원문 줄 교체 삽입 단위 회귀 테스트 |
| tests/markdown-slash-menu-scroll.test.js | 관리자 슬래시 메뉴 선택 항목 scrollTop 보정 단위 테스트 |
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태 관리 단위 테스트 |
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |

View File

@@ -303,10 +303,10 @@ components/content/
- 캡션(표시용): `![](url "캡션")` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
- **파일명을 캡션으로 사용** 토글: URL 파일명을 캡션으로 저장·표시(`![](url "파일명")`). 레거시 `![파일명](url)`도 동일하게 해석
- 와이드/풀: `![alt](url){width=wide|full}` 또는 캡션·width 조합
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 `lib/content-gallery-images.js` 기준 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다.
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 `lib/content-gallery-images.js` 기준 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다. 라이트박스 상태와 키보드 이벤트 연결은 `useContentMarkdownLightbox`에서 관리한다.
- 이미지 갤러리
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음). 행 분할과 라이트박스 목록 생성은 `lib/content-gallery-images.js`에서 수행한다.
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음). 행 분할과 라이트박스 목록 생성은 `lib/content-gallery-images.js`에서 수행하고, 라이트박스 상태 관리는 `useContentMarkdownLightbox`에서 수행한다.
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
- 비디오·오디오·파일 카드
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)

View File

@@ -4,7 +4,7 @@
- 거대 컴포넌트 책임 분리 진행.
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·광고 삽입 책임을 분리.
- 미디어 카탈로그 전환 코드 수명 정리.
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.11.95
- 공개 Markdown 렌더러의 이미지 라이트박스 상태·좌우 이동·ESC 닫기·전역 키보드 이벤트 관리를 `useContentMarkdownLightbox` composable로 분리.
- 라이트박스 키보드 이벤트 중복 연결 방지와 닫힘 시 해제 동작 단위 테스트 추가.
## v1.11.94
- 관리자 Markdown 에디터의 슬래시 메뉴 상태·억제 줄·키보드 탐색 타이머 관리를 `useAdminMarkdownSlashMenu` composable로 분리.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,129 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useContentMarkdownLightbox } from '../composables/useContentMarkdownLightbox.js'
/**
* 라이트박스 키보드 이벤트 테스트용 대상을 만든다.
* @returns {{ target: { addEventListener: Function, removeEventListener: Function }, listeners: Set<Function>, addCount: () => number, removeCount: () => number }} 이벤트 대상
*/
const createKeyboardTarget = () => {
const listeners = new Set()
let added = 0
let removed = 0
return {
listeners,
addCount: () => added,
removeCount: () => removed,
target: {
addEventListener: (type, handler) => {
if (type !== 'keydown') {
return
}
added += 1
listeners.add(handler)
},
removeEventListener: (type, handler) => {
if (type !== 'keydown') {
return
}
removed += 1
listeners.delete(handler)
}
}
}
}
/**
* 키보드 이벤트 스텁을 만든다.
* @param {string} key - 키 이름
* @returns {{ key: string, prevented: boolean, preventDefault: Function }} 이벤트 스텁
*/
const createKeyboardEvent = (key) => ({
key,
prevented: false,
preventDefault () {
this.prevented = true
}
})
test('본문 라이트박스는 키로 이미지를 열고 순환 이동한다', () => {
const { target } = createKeyboardTarget()
const lightbox = useContentMarkdownLightbox({ getKeyboardTarget: () => target })
const images = [
{ lightboxKey: 'a', url: '/a.jpg' },
{ lightboxKey: 'b', url: '/b.jpg' },
{ lightboxKey: 'c', url: '/c.jpg' }
]
lightbox.openContentLightbox(images, 'b')
assert.equal(lightbox.activeLightboxImage.value.url, '/b.jpg')
lightbox.showNextImage()
assert.equal(lightbox.activeLightboxImage.value.url, '/c.jpg')
lightbox.showNextImage()
assert.equal(lightbox.activeLightboxImage.value.url, '/a.jpg')
lightbox.showPreviousImage()
assert.equal(lightbox.activeLightboxImage.value.url, '/c.jpg')
})
test('본문 라이트박스는 열릴 때 키보드 이벤트를 한 번만 연결하고 닫힐 때 해제한다', () => {
const keyboard = createKeyboardTarget()
const lightbox = useContentMarkdownLightbox({ getKeyboardTarget: () => keyboard.target })
const images = [
{ lightboxKey: 'a', url: '/a.jpg' },
{ lightboxKey: 'b', url: '/b.jpg' }
]
lightbox.openLightbox(images, 0)
lightbox.openLightbox(images, 1)
assert.equal(keyboard.addCount(), 1)
assert.equal(keyboard.listeners.size, 1)
assert.equal(lightbox.activeLightboxImage.value.url, '/b.jpg')
const event = createKeyboardEvent('Escape')
const [handler] = keyboard.listeners
handler(event)
assert.equal(event.prevented, true)
assert.equal(lightbox.activeLightboxImage.value, undefined)
assert.equal(keyboard.removeCount(), 1)
assert.equal(keyboard.listeners.size, 0)
})
test('본문 라이트박스 키보드 조작은 좌우 이동과 단일 이미지 예외를 처리한다', () => {
const lightbox = useContentMarkdownLightbox()
const images = [
{ lightboxKey: 'a', url: '/a.jpg' },
{ lightboxKey: 'b', url: '/b.jpg' }
]
lightbox.openLightbox(images, 0)
const rightEvent = createKeyboardEvent('ArrowRight')
lightbox.handleLightboxKeydown(rightEvent)
assert.equal(rightEvent.prevented, true)
assert.equal(lightbox.activeLightboxImage.value.url, '/b.jpg')
const leftEvent = createKeyboardEvent('ArrowLeft')
lightbox.handleLightboxKeydown(leftEvent)
assert.equal(leftEvent.prevented, true)
assert.equal(lightbox.activeLightboxImage.value.url, '/a.jpg')
lightbox.openLightbox([{ lightboxKey: 'single', url: '/single.jpg' }], 0)
const singleLeftEvent = createKeyboardEvent('ArrowLeft')
lightbox.handleLightboxKeydown(singleLeftEvent)
assert.equal(singleLeftEvent.prevented, false)
assert.equal(lightbox.activeLightboxImage.value.url, '/single.jpg')
})