관리자 미디어 선택 상태 분리

This commit is contained in:
2026-07-27 18:30:19 +09:00
parent b3f3cbd0ec
commit a122ef46ac
12 changed files with 661 additions and 266 deletions

View File

@@ -37,26 +37,9 @@ import {
resolvePreviewFocusPosition as resolveMarkdownPreviewFocusPosition
} from '../../lib/markdown-editor-focus.js'
import {
buildMediaPickerApplyState,
buildMediaPickerEmptyMessage,
buildMediaPickerUploadHint,
buildMediaPickerPageItems,
buildMediaPickerRangeLabel,
createMediaBlockMarkdown,
createDefaultMediaPickerPagination,
createSelectedMediaInsertPlan,
findUploadSizeLimitError,
getMediaPickerAcceptForTarget,
getMediaPickerKindForTarget,
getGallerySelectionOrder as getGallerySelectionOrderFromSelection,
getMediaPickerTitleForTarget,
isGalleryMediaPickerTarget,
isMediaItemSelectableForTarget,
isUploadFileAllowedForMediaTarget,
normalizeMediaPickerPage,
normalizeMediaPickerResponse,
resolveSelectedMediaItems,
resolveUploadFetchErrorMessage,
toggleMediaSelectionState
resolveUploadFetchErrorMessage
} from '../../lib/markdown-editor-media.js'
import {
createSlashCommandApplyPlan,
@@ -81,6 +64,7 @@ import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSlashMenu.js'
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js'
import {
buildDefaultUploadSizeLimits,
DEFAULT_UPLOAD_MAX_FILE_COUNT,
@@ -138,23 +122,6 @@ const gutterLineHeights = ref([])
const pendingWriteFocusLine = ref(null)
/** 소스 모드에서 라이브 모드로 넘어갈 때 복원할 줄·오프셋 */
const pendingPreviewFocus = ref(null)
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
const isUploading = ref(false)
const mediaPickerTarget = ref('image')
const activeMediaPickerTab = ref('upload')
const mediaSearchQuery = ref('')
const {
displayValue: mediaSearchInput,
onInput: onMediaSearchInput,
onCompositionEnd: onMediaSearchCompositionEnd
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
const mediaPickerPage = ref(1)
const mediaPickerPageSize = 50
const mediaPickerPagination = ref(createDefaultMediaPickerPagination(mediaPickerPageSize))
const selectedMediaUrls = ref([])
const selectedMediaItemMap = ref({})
const lastSelectionState = ref({
start: 0,
end: 0
@@ -181,13 +148,49 @@ const {
startLiveSlashKeyboardNavigation,
stopLiveSlashKeyboardNavigation
} = useAdminMarkdownSlashMenu()
const {
mediaItems,
isMediaPickerOpen,
isLoadingMedia,
isUploading,
mediaPickerTarget,
activeMediaPickerTab,
mediaSearchQuery,
mediaSearchInput,
onMediaSearchInput,
onMediaSearchCompositionEnd,
mediaPickerPage,
mediaPickerPageSize,
selectedMediaUrls,
mediaPickerAccept,
mediaPickerKind,
mediaPickerTitle,
isGalleryMediaPicker,
mediaPickerPageCount,
filteredMediaItems,
mediaPickerRangeLabel,
mediaPickerPageItems,
mediaPickerUploadHint,
mediaPickerEmptyMessage,
mediaPickerApplyState,
isMediaItemSelectableForCurrentTarget,
isUploadFileAllowedForPicker,
openMediaPickerState,
closeMediaPickerState,
applyMediaPickerResponse,
setMediaPickerPage: setMediaPickerPageState,
toggleMediaSelection,
resolveCurrentSelectedMediaItems,
selectUploadedMediaFiles,
prependUploadedMediaItems,
getGallerySelectionOrder
} = useAdminMarkdownMediaPicker({
uploadSizeLimits
})
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 미리보기 스크롤 영역 */
let liveSlashPreviewScrollElement = null
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */
let liveSlashTextareaScrollElement = null
const mediaPickerAccept = computed(() => getMediaPickerAcceptForTarget(mediaPickerTarget.value))
const mediaPickerKind = computed(() => getMediaPickerKindForTarget(mediaPickerTarget.value))
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
@@ -1646,13 +1649,6 @@ const parseEditorImageLine = (line) => {
}
}
/**
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 선택 가능 여부
*/
const isMediaItemSelectableForCurrentTarget = (item) => isMediaItemSelectableForTarget(item, mediaPickerTarget.value)
/**
* 미디어 선택 모달 파생 이미지 로드 실패 시 원본 URL로 되돌린다.
* @param {Event} event - 이미지 오류 이벤트
@@ -1670,15 +1666,6 @@ const onMediaPickerImageError = (event, item) => {
image.src = String(item?.url || '')
}
/**
* 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다.
* @param {File} file - 브라우저 파일
* @returns {boolean} 허용 여부
*/
const isUploadFileAllowedForPicker = (file) => {
return isUploadFileAllowedForMediaTarget(file, mediaPickerTarget.value)
}
/**
* 지정 줄 범위를 새 줄 목록으로 교체한다.
* @param {number} startLine - 시작 줄
@@ -2843,10 +2830,7 @@ const fetchMediaItems = async () => {
}
})
const normalizedResponse = normalizeMediaPickerResponse(response, mediaPickerPageSize)
mediaItems.value = normalizedResponse.items
mediaPickerPagination.value = normalizedResponse.pagination
mediaPickerPage.value = mediaPickerPagination.value.page || 1
applyMediaPickerResponse(response)
} finally {
isLoadingMedia.value = false
}
@@ -2862,14 +2846,7 @@ const openMediaPicker = async (target) => {
ensureBlockPanelEngaged()
}
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaPickerPagination.value = createDefaultMediaPickerPagination(mediaPickerPageSize)
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
isMediaPickerOpen.value = true
openMediaPickerState(target)
}
defineExpose({
@@ -2897,65 +2874,20 @@ defineExpose({
* @returns {void}
*/
const closeMediaPicker = () => {
if (isUploading.value) {
if (!closeMediaPickerState()) {
return
}
isMediaPickerOpen.value = false
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaItems.value = []
mediaPickerPagination.value = createDefaultMediaPickerPagination(mediaPickerPageSize)
liveSlashInsertLine.value = null
}
/**
* 현재 미디어 선택 페이지의 표시 목록
* @returns {Array<Object>} 표시할 미디어 목록
*/
const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForCurrentTarget))
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
const mediaPickerRangeLabel = computed(() => {
return buildMediaPickerRangeLabel({
pagination: mediaPickerPagination.value,
currentPage: mediaPickerPage.value,
pageCount: mediaPickerPageCount.value,
defaultPageSize: mediaPickerPageSize
})
})
const mediaPickerPageItems = computed(() => {
return buildMediaPickerPageItems({
currentPage: mediaPickerPage.value,
pageCount: mediaPickerPageCount.value
})
})
/**
* 갤러리 미디어 선택 순서를 반환한다.
* @param {string} url - 미디어 URL
* @returns {number} 1부터 시작하는 선택 순서, 선택되지 않았으면 0
*/
const getGallerySelectionOrder = (url) => {
if (!isGalleryMediaPicker.value) {
return 0
}
return getGallerySelectionOrderFromSelection(selectedMediaUrls.value, url)
}
/**
* 미디어 선택 모달 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {Promise<void>}
*/
const setMediaPickerPage = async (page) => {
mediaPickerPage.value = normalizeMediaPickerPage(page, mediaPickerPageCount.value)
setMediaPickerPageState(page)
await fetchMediaItems()
}
@@ -2977,91 +2909,34 @@ watch(activeMediaPickerTab, async (tab) => {
await fetchMediaItems()
})
/**
* 미디어 선택 상태를 토글한다.
* @param {Object} mediaItem - 미디어 항목
* @returns {void}
*/
const toggleMediaSelection = (mediaItem) => {
const nextSelection = toggleMediaSelectionState({
selectedUrls: selectedMediaUrls.value,
selectedItemMap: selectedMediaItemMap.value,
mediaItem,
isGallery: isGalleryMediaPicker.value
})
selectedMediaUrls.value = nextSelection.selectedUrls
selectedMediaItemMap.value = nextSelection.selectedItemMap
}
/**
* 미디어 항목 목록을 마크다운에 삽입한다.
* @param {Array<Object>} selectedItems - 선택 또는 업로드된 미디어 항목
* @returns {void}
*/
const insertSelectedMediaItems = (selectedItems) => {
const plan = createSelectedMediaInsertPlan({
target: mediaPickerTarget.value,
selectedItems,
liveSlashLine: liveSlashInsertLine.value
})
if (liveSlashInsertLine.value !== null) {
const line = liveSlashInsertLine.value
if (mediaPickerTarget.value === 'image') {
const [item] = selectedItems
if (item) {
insertMarkdownAtLine(line, [createImageMarkdown({
url: item.url,
useAlt: false,
caption: ''
})])
}
} else if (mediaPickerTarget.value === 'gallery' && selectedItems.length) {
insertMarkdownAtLine(line, [
':::gallery',
...selectedItems.map((item) => createImageMarkdown({
url: item.url,
useAlt: false,
caption: ''
})),
':::'
])
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
const [item] = selectedItems
if (item) {
insertMarkdownAtLine(line, createMediaBlockMarkdown(mediaPickerTarget.value, item))
}
if (plan.kind === 'insert-at-line') {
insertMarkdownAtLine(plan.line, plan.lines)
}
liveSlashInsertLine.value = null
return
}
if (mediaPickerTarget.value === 'image') {
const [item] = selectedItems
if (item) {
insertImage({
url: item.url,
useAlt: false,
caption: ''
})
}
} else if (mediaPickerTarget.value === 'active-gallery') {
appendImagesToActiveGallery(selectedItems.map((item) => ({
url: item.url,
useAlt: false,
caption: ''
})))
} else if (mediaPickerTarget.value === 'gallery') {
insertGallery(selectedItems.map((item) => ({
url: item.url,
useAlt: false,
caption: ''
})))
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
const [item] = selectedItems
if (item) {
insertBlockSnippet(createMediaBlockMarkdown(mediaPickerTarget.value, item).join('\n'))
}
if (plan.kind === 'image') {
insertImage(plan.image)
} else if (plan.kind === 'active-gallery') {
appendImagesToActiveGallery(plan.images)
} else if (plan.kind === 'gallery') {
insertGallery(plan.images)
} else if (plan.kind === 'block') {
insertBlockSnippet(plan.lines.join('\n'))
}
}
@@ -3070,12 +2945,7 @@ const insertSelectedMediaItems = (selectedItems) => {
* @returns {void}
*/
const applyMediaSelection = () => {
const selectedItems = resolveSelectedMediaItems(
selectedMediaUrls.value,
selectedMediaItemMap.value,
mediaItems.value
)
const selectedItems = resolveCurrentSelectedMediaItems()
insertSelectedMediaItems(selectedItems)
closeMediaPicker()
}
@@ -3125,18 +2995,18 @@ const uploadAndInsert = async (files, target = 'image') => {
try {
const uploadedFiles = await uploadMediaFiles(files)
const imageItems = uploadedFiles.map((file) => ({
url: file.url,
useAlt: false,
caption: ''
}))
const plan = createSelectedMediaInsertPlan({
target,
selectedItems: uploadedFiles,
liveSlashLine: null
})
if (target === 'gallery') {
insertGallery(imageItems)
} else if (target === 'image' && imageItems[0]) {
insertImage(imageItems[0])
} else if (['video', 'audio', 'file'].includes(target) && uploadedFiles[0]) {
insertBlockSnippet(createMediaBlockMarkdown(target, uploadedFiles[0]).join('\n'))
if (plan.kind === 'gallery') {
insertGallery(plan.images)
} else if (plan.kind === 'image') {
insertImage(plan.image)
} else if (plan.kind === 'block') {
insertBlockSnippet(plan.lines.join('\n'))
}
if (isMediaPickerOpen.value && activeMediaPickerTab.value === 'library') {
@@ -3174,28 +3044,12 @@ const uploadFromMediaModal = async (files) => {
try {
const uploadedFiles = await uploadMediaFiles(files)
const uploadedUrls = uploadedFiles
.filter(isMediaItemSelectableForCurrentTarget)
.map((file) => file.url)
selectedMediaItemMap.value = uploadedFiles.reduce((map, file) => {
if (uploadedUrls.includes(file.url)) {
map[file.url] = file
}
return map
}, {})
selectedMediaUrls.value = isGalleryMediaPicker.value
? uploadedUrls
: uploadedUrls.slice(0, 1)
selectUploadedMediaFiles(uploadedFiles)
activeMediaPickerTab.value = 'library'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
await fetchMediaItems().catch(() => {
mediaItems.value = [
...uploadedFiles,
...mediaItems.value
]
prependUploadedMediaItems(uploadedFiles)
})
showToast('success', '업로드가 완료되어 파일이 선택되었습니다.')
} catch (error) {
@@ -3226,43 +3080,6 @@ const handleMediaModalDrop = async (event) => {
await uploadFromMediaModal(files)
}
/**
* 미디어 모달의 삽입 대상 라벨을 반환한다.
* @returns {string} 모달 제목
*/
const mediaPickerTitle = computed(() => getMediaPickerTitleForTarget(mediaPickerTarget.value))
/**
* 미디어 모달에서 다중 선택 여부를 반환한다.
* @returns {boolean} 다중 선택 여부
*/
const isGalleryMediaPicker = computed(() => isGalleryMediaPickerTarget(mediaPickerTarget.value))
/**
* 미디어 업로드 안내 문구를 반환한다.
* @returns {string} 안내 문구
*/
const mediaPickerUploadHint = computed(() => buildMediaPickerUploadHint(
mediaPickerTarget.value,
uploadSizeLimits.value
))
/**
* 미디어 라이브러리 빈 상태 문구를 반환한다.
* @returns {string} 빈 상태 문구
*/
const mediaPickerEmptyMessage = computed(() => buildMediaPickerEmptyMessage(mediaSearchQuery.value))
/**
* 미디어 선택 적용 버튼 상태를 반환한다.
* @returns {{ disabled: boolean, label: string }} 적용 버튼 상태
*/
const mediaPickerApplyState = computed(() => buildMediaPickerApplyState({
selectedUrls: selectedMediaUrls.value,
isUploading: isUploading.value,
isGallery: isGalleryMediaPicker.value
}))
/**
* 붙여넣은 이미지 파일을 업로드한다.
* @param {ClipboardEvent} event - 붙여넣기 이벤트

View File

@@ -0,0 +1,271 @@
import { computed, ref, unref } from 'vue'
import {
buildMediaPickerApplyState,
buildMediaPickerEmptyMessage,
buildMediaPickerPageItems,
buildMediaPickerRangeLabel,
buildMediaPickerUploadHint,
createDefaultMediaPickerPagination,
getGallerySelectionOrder as getGallerySelectionOrderFromSelection,
getMediaPickerAcceptForTarget,
getMediaPickerKindForTarget,
getMediaPickerTitleForTarget,
isGalleryMediaPickerTarget,
isMediaItemSelectableForTarget,
isUploadFileAllowedForMediaTarget,
normalizeMediaPickerPage,
normalizeMediaPickerResponse,
resolveSelectedMediaItems,
toggleMediaSelectionState
} from '../lib/markdown-editor-media.js'
import { useImeSearchInput } from './useImeSearchInput.js'
export const ADMIN_MARKDOWN_MEDIA_PICKER_PAGE_SIZE = 50
/**
* 관리자 Markdown 에디터의 미디어 선택 모달 상태를 관리한다.
* @param {Object} options - 상태 옵션
* @param {import('vue').Ref<Object>|import('vue').ComputedRef<Object>} options.uploadSizeLimits - 업로드 한도
* @param {number} [options.pageSize=50] - 페이지 크기
* @returns {Object} 미디어 선택 모달 상태와 액션
*/
export const useAdminMarkdownMediaPicker = ({
uploadSizeLimits,
pageSize = ADMIN_MARKDOWN_MEDIA_PICKER_PAGE_SIZE
}) => {
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
const isUploading = ref(false)
const mediaPickerTarget = ref('image')
const activeMediaPickerTab = ref('upload')
const mediaSearchQuery = ref('')
const mediaPickerPage = ref(1)
const mediaPickerPagination = ref(createDefaultMediaPickerPagination(pageSize))
const selectedMediaUrls = ref([])
const selectedMediaItemMap = ref({})
const {
displayValue: mediaSearchInput,
onInput: onMediaSearchInput,
onCompositionEnd: onMediaSearchCompositionEnd
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
const mediaPickerAccept = computed(() => getMediaPickerAcceptForTarget(mediaPickerTarget.value))
const mediaPickerKind = computed(() => getMediaPickerKindForTarget(mediaPickerTarget.value))
const mediaPickerTitle = computed(() => getMediaPickerTitleForTarget(mediaPickerTarget.value))
const isGalleryMediaPicker = computed(() => isGalleryMediaPickerTarget(mediaPickerTarget.value))
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForCurrentTarget))
const mediaPickerRangeLabel = computed(() => buildMediaPickerRangeLabel({
pagination: mediaPickerPagination.value,
currentPage: mediaPickerPage.value,
pageCount: mediaPickerPageCount.value,
defaultPageSize: pageSize
}))
const mediaPickerPageItems = computed(() => buildMediaPickerPageItems({
currentPage: mediaPickerPage.value,
pageCount: mediaPickerPageCount.value
}))
const mediaPickerUploadHint = computed(() => buildMediaPickerUploadHint(
mediaPickerTarget.value,
unref(uploadSizeLimits)
))
const mediaPickerEmptyMessage = computed(() => buildMediaPickerEmptyMessage(mediaSearchQuery.value))
const mediaPickerApplyState = computed(() => buildMediaPickerApplyState({
selectedUrls: selectedMediaUrls.value,
isUploading: isUploading.value,
isGallery: isGalleryMediaPicker.value
}))
/**
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 선택 가능 여부
*/
function isMediaItemSelectableForCurrentTarget(item) {
return isMediaItemSelectableForTarget(item, mediaPickerTarget.value)
}
/**
* 업로드 파일이 현재 선택 대상에 맞는지 확인한다.
* @param {File} file - 브라우저 파일
* @returns {boolean} 허용 여부
*/
const isUploadFileAllowedForPicker = (file) => {
return isUploadFileAllowedForMediaTarget(file, mediaPickerTarget.value)
}
/**
* 미디어 선택 모달 상태를 연다.
* @param {string} target - 삽입 대상
* @returns {void}
*/
const openMediaPickerState = (target) => {
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaPickerPagination.value = createDefaultMediaPickerPagination(pageSize)
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
isMediaPickerOpen.value = true
}
/**
* 미디어 선택 모달 상태를 닫는다.
* @returns {boolean} 닫힘 처리 여부
*/
const closeMediaPickerState = () => {
if (isUploading.value) {
return false
}
isMediaPickerOpen.value = false
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaItems.value = []
mediaPickerPagination.value = createDefaultMediaPickerPagination(pageSize)
return true
}
/**
* 미디어 API 응답을 현재 상태에 반영한다.
* @param {Object} response - 미디어 API 응답
* @returns {void}
*/
const applyMediaPickerResponse = (response) => {
const normalizedResponse = normalizeMediaPickerResponse(response, pageSize)
mediaItems.value = normalizedResponse.items
mediaPickerPagination.value = normalizedResponse.pagination
mediaPickerPage.value = mediaPickerPagination.value.page || 1
}
/**
* 미디어 선택 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {void}
*/
const setMediaPickerPage = (page) => {
mediaPickerPage.value = normalizeMediaPickerPage(page, mediaPickerPageCount.value)
}
/**
* 미디어 선택 상태를 토글한다.
* @param {Object} mediaItem - 미디어 항목
* @returns {void}
*/
const toggleMediaSelection = (mediaItem) => {
const nextSelection = toggleMediaSelectionState({
selectedUrls: selectedMediaUrls.value,
selectedItemMap: selectedMediaItemMap.value,
mediaItem,
isGallery: isGalleryMediaPicker.value
})
selectedMediaUrls.value = nextSelection.selectedUrls
selectedMediaItemMap.value = nextSelection.selectedItemMap
}
/**
* 선택된 미디어 항목을 현재 목록과 선택 맵에서 해석한다.
* @returns {Object[]} 선택된 미디어 항목
*/
const resolveCurrentSelectedMediaItems = () => resolveSelectedMediaItems(
selectedMediaUrls.value,
selectedMediaItemMap.value,
mediaItems.value
)
/**
* 업로드 직후 선택 상태를 갱신한다.
* @param {Object[]} uploadedFiles - 업로드된 파일 목록
* @returns {string[]} 선택된 URL 목록
*/
const selectUploadedMediaFiles = (uploadedFiles) => {
const uploadedUrls = (Array.isArray(uploadedFiles) ? uploadedFiles : [])
.filter(isMediaItemSelectableForCurrentTarget)
.map((file) => file.url)
selectedMediaItemMap.value = (Array.isArray(uploadedFiles) ? uploadedFiles : []).reduce((map, file) => {
if (uploadedUrls.includes(file.url)) {
map[file.url] = file
}
return map
}, {})
selectedMediaUrls.value = isGalleryMediaPicker.value
? uploadedUrls
: uploadedUrls.slice(0, 1)
return uploadedUrls
}
/**
* 업로드된 파일을 목록 앞에 임시로 합친다.
* @param {Object[]} uploadedFiles - 업로드된 파일 목록
* @returns {void}
*/
const prependUploadedMediaItems = (uploadedFiles) => {
mediaItems.value = [
...(Array.isArray(uploadedFiles) ? uploadedFiles : []),
...mediaItems.value
]
}
/**
* 갤러리 미디어 선택 순서를 반환한다.
* @param {string} url - 미디어 URL
* @returns {number} 1부터 시작하는 선택 순서
*/
const getGallerySelectionOrder = (url) => {
if (!isGalleryMediaPicker.value) {
return 0
}
return getGallerySelectionOrderFromSelection(selectedMediaUrls.value, url)
}
return {
mediaItems,
isMediaPickerOpen,
isLoadingMedia,
isUploading,
mediaPickerTarget,
activeMediaPickerTab,
mediaSearchQuery,
mediaSearchInput,
onMediaSearchInput,
onMediaSearchCompositionEnd,
mediaPickerPage,
mediaPickerPageSize: pageSize,
mediaPickerPagination,
selectedMediaUrls,
selectedMediaItemMap,
mediaPickerAccept,
mediaPickerKind,
mediaPickerTitle,
isGalleryMediaPicker,
mediaPickerPageCount,
filteredMediaItems,
mediaPickerRangeLabel,
mediaPickerPageItems,
mediaPickerUploadHint,
mediaPickerEmptyMessage,
mediaPickerApplyState,
isMediaItemSelectableForCurrentTarget,
isUploadFileAllowedForPicker,
openMediaPickerState,
closeMediaPickerState,
applyMediaPickerResponse,
setMediaPickerPage,
toggleMediaSelection,
resolveCurrentSelectedMediaItems,
selectUploadedMediaFiles,
prependUploadedMediaItems,
getGallerySelectionOrder
}
}

View File

@@ -1,3 +1,7 @@
## 2026-07-27 v1.11.107 — 미디어 선택 모달 상태를 에디터 밖으로 옮긴다
관리자 Markdown 에디터의 미디어 선택 모달은 검색어, IME 입력, 탭, 페이지네이션, 선택 순서, 업로드 중 닫기 방지, 업로드 직후 선택 상태까지 함께 관리한다. 이 상태를 에디터 본문 삽입 함수와 같은 파일에 두면 모달 UI 상태 변경과 실제 마크다운 편집 부수 효과가 계속 섞인다. 따라서 모달 상태와 표시 계산은 `useAdminMarkdownMediaPicker`가 담당하고, 선택한 미디어를 어떤 방식으로 삽입할지 계산하는 규칙은 `markdown-editor-media` helper로 분리한다. 에디터는 서버 조회·업로드·본문 삽입 실행만 맡도록 책임을 좁힌다.
## 2026-07-27 v1.11.106 — 슬래시 명령 적용 판단을 helper로 모은다
관리자 Markdown 에디터의 슬래시 명령은 미디어 선택 모달을 여는 명령과 마크다운 줄을 바로 삽입하는 명령으로 나뉜다. 이 판단을 컴포넌트 안에 두면 메뉴 상태, 줄 교체, 포커스 복원, 모달 열기가 한 함수에서 섞이므로 `markdown-slash-commands` helper가 적용 계획을 계산하게 한다. 컴포넌트는 계산된 계획에 따라 실제 UI 부수 효과만 수행해 이후 미디어 모달과 포커스 연결을 더 작게 분리할 수 있게 한다.

View File

@@ -27,6 +27,7 @@
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·억제 줄·키보드 탐색 타이머 관리 |
| composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 |
| composables/useAdminMarkdownMediaPicker.js | 관리자 Markdown 에디터 미디어 선택 모달 열기·닫기·탭·검색어·페이지·선택 상태·표시 계산 관리 |
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
| composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 |
@@ -61,7 +62,7 @@
| lib/markdown-editor-history.js | 관리자 Markdown-first 에디터 undo/redo 히스토리 항목 생성, 레거시 문자열 항목 본문 추출, 선택 범위 보정 |
| lib/markdown-editor-keyboard.js | 관리자 Markdown-first 에디터 전역 단축키 판별(Cmd/Ctrl 조합, undo/redo, 작성/미리보기 전환, 미디어 선택 닫기) |
| lib/markdown-source-selection.js | 관리자 Markdown-first 소스 textarea 선택 상태, 본문 치환, 블록 조각 삽입, 인라인 링크·감싸기, 줄 변환·삭제 계산 |
| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 대상별 제목·accept·API kind·업로드 안내, 라이브러리 빈 상태 문구, 적용 버튼 상태, 선택 순서·토글·항목 해석, 업로드 오류·한도 검증, 페이지 상태·범위·페이지 번호 보정·페이지 번호 계산, fenced 미디어 블록 생성 |
| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 대상별 제목·accept·API kind·업로드 안내, 라이브러리 빈 상태 문구, 적용 버튼 상태, 선택 순서·토글·항목 해석, 선택 미디어 삽입 계획, 업로드 오류·한도 검증, 페이지 상태·범위·페이지 번호 보정·페이지 번호 계산, fenced 미디어 블록 생성 |
| lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 |
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
| lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출, `allowfullscreen`을 `allow=fullscreen` 권한으로 정규화 |
@@ -138,11 +139,12 @@
| tests/markdown-editor-history.test.js | 관리자 Markdown 에디터 undo/redo 히스토리 항목과 선택 범위 보정 단위 회귀 테스트 |
| tests/markdown-editor-keyboard.test.js | 관리자 Markdown 에디터 전역 단축키 판별 단위 회귀 테스트 |
| tests/markdown-source-selection.test.js | 관리자 Markdown 에디터 소스 textarea 선택·치환·줄 변환 helper 단위 테스트 |
| tests/markdown-editor-media.test.js | 관리자 Markdown 에디터 미디어 선택 helper의 종류·선택 가능 여부·미리보기·선택 상태·블록 생성 단위 회귀 테스트 |
| tests/markdown-editor-media.test.js | 관리자 Markdown 에디터 미디어 선택 helper의 종류·선택 가능 여부·미리보기·선택 상태·삽입 계획·블록 생성 단위 회귀 테스트 |
| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·모달 종류·적용 계획·제외 줄 Set 갱신·하이라이트 인덱스 보정·방향키 순환·키보드 동작 분류·포커스 대상·textarea 선택 위치·원문 줄 교체 삽입 단위 회귀 테스트 |
| tests/markdown-slash-menu-scroll.test.js | 관리자 슬래시 메뉴 선택 항목 scrollTop 보정 단위 테스트 |
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태 관리 단위 테스트 |
| tests/useAdminMarkdownHistory.test.js | 관리자 Markdown 에디터 히스토리 composable의 기록·undo/redo·그룹화·보관 개수 단위 테스트 |
| tests/useAdminMarkdownMediaPicker.test.js | 관리자 Markdown 에디터 미디어 선택 모달 composable 상태 관리 단위 테스트 |
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
| tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 |

View File

@@ -112,7 +112,7 @@
- Markdown 리스트 마커는 항목이 여러 줄로 줄바꿈되어도 항목 전체 높이 중앙이 아니라 첫 줄 기준으로 정렬한다.
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다. 표 헤더와 셀 내부의 인라인 링크·굵게·기울임·인라인 코드를 렌더링하고, 셀 내부 `<br>` 또는 `<br/>`은 줄바꿈으로 표시한다. 링크 텍스트 안의 `**굵게**` 같은 중첩 인라인 문법도 렌더링한다.
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다. 인용 본문 안의 `::red[**텍스트**]` 같은 색상·굵게·링크 등 인라인 문법은 일반 문단과 같은 규칙으로 렌더링한다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 단일 이미지는 전체 너비(자동)·갤러리 2장 1칸(356px)·갤러리 3장 1칸(235px)·갤러리 3장 2칸(477px)·직접 입력 셀렉트로 표시 너비를 고를 수 있으며 저장 시 이미지 마크다운 뒤에 `{size=숫자}` 옵션을 붙인다. 공개 본문은 이 값을 최대 너비로 적용하고 원본 비율은 유지한다. 직접 입력은 120~1600px 범위로 저장하되 입력 중에는 범위 보정을 즉시 강제하지 않는다. 콜아웃은 제목·아이콘 표시 ON/OFF 토글·아이콘·배경색, 코드 블록은 언어 셀렉트·줄번호 표시 ON/OFF 토글, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘 입력·프리셋은 아이콘 표시가 꺼진 상태에서 흐린 비활성 영역으로 표시하고, 토글을 켠 뒤에만 조작한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 인용·콜아웃·토글 본문은 라이브 모드에서 굵게·링크·색상 같은 인라인 문법과 기본 불릿/번호 목록을 표시한다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 소스 textarea의 선택 치환·인라인 감싸기·링크 삽입·줄 변환 계산은 `markdown-source-selection` helper가 담당하고, 라이브 전환 포커스 대상 보정과 코드 펜스 본문 줄 판별은 `markdown-editor-focus` helper가 담당한다. 슬래시 명령의 미디어 모달·마크다운 줄 삽입 적용 계획은 `markdown-slash-commands` helper가 계산한다. 에디터는 실제 포커스·스크롤 복원만 처리한다. 에디터 되돌리기·다시 실행은 `useAdminMarkdownHistory`에서 본문 값과 선택 위치를 함께 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다. 인용 편집 중 브라우저 자동 목록 보정으로 `>. ` 접두가 섞이면 커밋 시 정리한다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 단일 이미지는 전체 너비(자동)·갤러리 2장 1칸(356px)·갤러리 3장 1칸(235px)·갤러리 3장 2칸(477px)·직접 입력 셀렉트로 표시 너비를 고를 수 있으며 저장 시 이미지 마크다운 뒤에 `{size=숫자}` 옵션을 붙인다. 공개 본문은 이 값을 최대 너비로 적용하고 원본 비율은 유지한다. 직접 입력은 120~1600px 범위로 저장하되 입력 중에는 범위 보정을 즉시 강제하지 않는다. 콜아웃은 제목·아이콘 표시 ON/OFF 토글·아이콘·배경색, 코드 블록은 언어 셀렉트·줄번호 표시 ON/OFF 토글, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘 입력·프리셋은 아이콘 표시가 꺼진 상태에서 흐린 비활성 영역으로 표시하고, 토글을 켠 뒤에만 조작한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 인용·콜아웃·토글 본문은 라이브 모드에서 굵게·링크·색상 같은 인라인 문법과 기본 불릿/번호 목록을 표시한다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 소스 textarea의 선택 치환·인라인 감싸기·링크 삽입·줄 변환 계산은 `markdown-source-selection` helper가 담당하고, 라이브 전환 포커스 대상 보정과 코드 펜스 본문 줄 판별은 `markdown-editor-focus` helper가 담당한다. 슬래시 명령의 미디어 모달·마크다운 줄 삽입 적용 계획은 `markdown-slash-commands` helper가 계산한다. 미디어 선택 모달의 상태·표시 계산은 `useAdminMarkdownMediaPicker`가 관리하고, 선택 미디어 삽입 계획은 `markdown-editor-media` helper가 계산한다. 에디터는 실제 포커스·스크롤 복원만 처리한다. 에디터 되돌리기·다시 실행은 `useAdminMarkdownHistory`에서 본문 값과 선택 위치를 함께 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다. 인용 편집 중 브라우저 자동 목록 보정으로 `>. ` 접두가 섞이면 커밋 시 정리한다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 최소 24px 높이와 세로 중앙 정렬을 사용하며, 2줄 이상 제목에서는 항목 높이에 맞춰 활성 표시선도 함께 늘어난다. 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 공개 회원 로그인 세션의 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다. 별도 관리자 세션만 있거나 비로그인 상태에서는 표시하지 않는다.

View File

@@ -3,7 +3,7 @@
## v1.11 내부 개선
- 거대 컴포넌트 책임 분리 진행.
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령 적용 후 DOM 포커스·미디어 모달 연결, 라이브/소스 전환 DOM 포커스 복원 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달의 서버 조회·업로드 실행 흐름, 슬래시 명령 적용 후 DOM 포커스·미디어 모달 연결, 라이브/소스 전환 DOM 포커스 복원 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
- 미디어 카탈로그 전환 코드 수명 정리.
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.107
- 관리자 Markdown 에디터의 미디어 선택 모달 상태·검색어·페이지·선택 상태·표시 계산을 `useAdminMarkdownMediaPicker` composable로 분리.
- 선택한 미디어를 슬래시 줄·단일 이미지·갤러리·미디어 블록으로 적용하는 계획을 `markdown-editor-media` helper로 분리.
- 미디어 선택 composable과 선택 미디어 삽입 계획 단위 테스트 추가.
## v1.11.106
- 관리자 Markdown 에디터의 슬래시 명령 적용 판단을 `markdown-slash-commands` helper로 분리.

View File

@@ -5,6 +5,7 @@ import {
isPostCardThumbnailUrl,
withGeneratedImageCacheVersion
} from './post-card-thumbnail-url.js'
import { serializeImageMarkdown } from './markdown-image.js'
import {
formatUploadSizeLimit,
getMaxUploadBytesForKind,
@@ -290,6 +291,96 @@ export const createMediaBlockMarkdown = (blockType, item) => {
]
}
/**
* 미디어 항목을 이미지 삽입용 데이터로 변환한다.
* @param {Object} item - 미디어 항목
* @returns {{ url: string, useAlt: boolean, caption: string }} 이미지 데이터
*/
export const createMediaImageInsertData = (item) => ({
url: item.url,
useAlt: false,
caption: ''
})
/**
* 선택된 미디어를 에디터 본문에 적용할 계획으로 변환한다.
* @param {Object} options - 계획 옵션
* @param {string} options.target - 미디어 선택 대상
* @param {Object[]} options.selectedItems - 선택된 미디어 항목
* @param {number|null} options.liveSlashLine - 슬래시 명령으로 삽입할 줄
* @returns {{ kind: 'none' }|{ kind: 'insert-at-line', line: number, lines: string[] }|{ kind: 'image', image: { url: string, useAlt: boolean, caption: string } }|{ kind: 'active-gallery', images: Array<{ url: string, useAlt: boolean, caption: string }> }|{ kind: 'gallery', images: Array<{ url: string, useAlt: boolean, caption: string }> }|{ kind: 'block', lines: string[] }} 적용 계획
*/
export const createSelectedMediaInsertPlan = ({
target,
selectedItems,
liveSlashLine
}) => {
const items = (Array.isArray(selectedItems) ? selectedItems : []).filter((item) => item?.url)
const line = Number.isFinite(liveSlashLine) ? Math.max(0, Math.floor(liveSlashLine)) : null
if (line !== null) {
if (target === 'image' && items[0]) {
return {
kind: 'insert-at-line',
line,
lines: [serializeImageMarkdown(createMediaImageInsertData(items[0]))]
}
}
if (target === 'gallery' && items.length) {
return {
kind: 'insert-at-line',
line,
lines: [
':::gallery',
...items.map((item) => serializeImageMarkdown(createMediaImageInsertData(item))),
':::'
]
}
}
if (['video', 'audio', 'file'].includes(target) && items[0]) {
return {
kind: 'insert-at-line',
line,
lines: createMediaBlockMarkdown(target, items[0])
}
}
return { kind: 'none' }
}
if (target === 'image' && items[0]) {
return {
kind: 'image',
image: createMediaImageInsertData(items[0])
}
}
if (target === 'active-gallery' && items.length) {
return {
kind: 'active-gallery',
images: items.map(createMediaImageInsertData)
}
}
if (target === 'gallery' && items.length) {
return {
kind: 'gallery',
images: items.map(createMediaImageInsertData)
}
}
if (['video', 'audio', 'file'].includes(target) && items[0]) {
return {
kind: 'block',
lines: createMediaBlockMarkdown(target, items[0])
}
}
return { kind: 'none' }
}
/**
* 갤러리 미디어 선택 순서를 반환한다.
* @param {string[]} selectedUrls - 선택된 URL 목록

4
package-lock.json generated
View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
buildMediaPickerUploadHint,
createMediaBlockMarkdown,
createDefaultMediaPickerPagination,
createSelectedMediaInsertPlan,
findUploadSizeLimitError,
formatMediaFileSize,
getGallerySelectionOrder,
@@ -157,6 +158,100 @@ test('미디어 항목으로 fenced 미디어 블록을 만든다', () => {
}), [':::file', 'url=/uploads/a.pdf', 'title=a.pdf', 'description=', 'name=a.pdf', 'size=2 KB', ':::'])
})
test('선택 미디어 삽입 계획은 슬래시 줄 삽입과 일반 삽입을 구분한다', () => {
assert.deepEqual(createSelectedMediaInsertPlan({
target: 'image',
selectedItems: [{ url: '/uploads/a.png' }],
liveSlashLine: 3
}), {
kind: 'insert-at-line',
line: 3,
lines: ['![](/uploads/a.png)']
})
assert.deepEqual(createSelectedMediaInsertPlan({
target: 'gallery',
selectedItems: [{ url: '/uploads/a.png' }, { url: '/uploads/b.png' }],
liveSlashLine: 1
}), {
kind: 'insert-at-line',
line: 1,
lines: [':::gallery', '![](/uploads/a.png)', '![](/uploads/b.png)', ':::']
})
assert.deepEqual(createSelectedMediaInsertPlan({
target: 'video',
selectedItems: [{ url: '/uploads/a.mp4', title: '영상' }],
liveSlashLine: 2
}), {
kind: 'insert-at-line',
line: 2,
lines: [':::video', 'url=/uploads/a.mp4', 'title=영상', 'caption=', ':::']
})
})
test('선택 미디어 삽입 계획은 일반 삽입 대상을 구분한다', () => {
assert.deepEqual(createSelectedMediaInsertPlan({
target: 'image',
selectedItems: [{ url: '/uploads/a.png' }],
liveSlashLine: null
}), {
kind: 'image',
image: {
url: '/uploads/a.png',
useAlt: false,
caption: ''
}
})
assert.deepEqual(createSelectedMediaInsertPlan({
target: 'active-gallery',
selectedItems: [{ url: '/uploads/a.png' }, { url: '/uploads/b.png' }],
liveSlashLine: null
}), {
kind: 'active-gallery',
images: [
{
url: '/uploads/a.png',
useAlt: false,
caption: ''
},
{
url: '/uploads/b.png',
useAlt: false,
caption: ''
}
]
})
assert.deepEqual(createSelectedMediaInsertPlan({
target: 'file',
selectedItems: [{ url: '/uploads/a.pdf', name: 'a.pdf', size: 2048 }],
liveSlashLine: null
}), {
kind: 'block',
lines: [':::file', 'url=/uploads/a.pdf', 'title=a.pdf', 'description=', 'name=a.pdf', 'size=2 KB', ':::']
})
})
test('선택 미디어 삽입 계획은 적용할 항목이 없으면 무시한다', () => {
assert.deepEqual(createSelectedMediaInsertPlan({
target: 'image',
selectedItems: [],
liveSlashLine: null
}), {
kind: 'none'
})
assert.deepEqual(createSelectedMediaInsertPlan({
target: 'unknown',
selectedItems: [{ url: '/uploads/a.png' }],
liveSlashLine: 1
}), {
kind: 'none'
})
})
test('갤러리 선택 순서를 선택 URL 순서로 반환한다', () => {
assert.equal(getGallerySelectionOrder(['/a.png', '/b.png'], '/b.png'), 2)
assert.equal(getGallerySelectionOrder(['/a.png'], '/b.png'), 0)

View File

@@ -0,0 +1,109 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { computed } from 'vue'
import { useAdminMarkdownMediaPicker } from '../composables/useAdminMarkdownMediaPicker.js'
const uploadSizeLimits = computed(() => ({
image: 10485760,
video: 209715200,
audio: 52428800,
document: 52428800
}))
test('관리자 Markdown 미디어 선택 모달은 열기와 닫기 상태를 관리한다', () => {
const picker = useAdminMarkdownMediaPicker({ uploadSizeLimits })
picker.openMediaPickerState('gallery')
assert.equal(picker.isMediaPickerOpen.value, true)
assert.equal(picker.mediaPickerTarget.value, 'gallery')
assert.equal(picker.activeMediaPickerTab.value, 'upload')
assert.equal(picker.mediaSearchQuery.value, '')
assert.equal(picker.mediaPickerPage.value, 1)
assert.deepEqual(picker.selectedMediaUrls.value, [])
assert.equal(picker.isGalleryMediaPicker.value, true)
assert.equal(picker.mediaPickerAccept.value, 'image/*')
assert.equal(picker.mediaPickerTitle.value, '갤러리 이미지 선택')
assert.equal(picker.closeMediaPickerState(), true)
assert.equal(picker.isMediaPickerOpen.value, false)
assert.equal(picker.activeMediaPickerTab.value, 'upload')
assert.deepEqual(picker.mediaItems.value, [])
})
test('관리자 Markdown 미디어 선택 모달은 업로드 중 닫기를 막는다', () => {
const picker = useAdminMarkdownMediaPicker({ uploadSizeLimits })
picker.openMediaPickerState('image')
picker.isUploading.value = true
assert.equal(picker.closeMediaPickerState(), false)
assert.equal(picker.isMediaPickerOpen.value, true)
})
test('관리자 Markdown 미디어 선택 모달은 API 응답과 페이지를 상태에 반영한다', () => {
const picker = useAdminMarkdownMediaPicker({ uploadSizeLimits, pageSize: 20 })
picker.applyMediaPickerResponse({
items: [{ url: '/uploads/a.png' }],
pagination: {
page: 3,
pageSize: 20,
total: 42,
pageCount: 3
}
})
assert.equal(picker.mediaPickerPage.value, 3)
assert.equal(picker.mediaPickerPageCount.value, 3)
assert.equal(picker.mediaPickerRangeLabel.value, '42개 중 41-42개')
assert.deepEqual(picker.filteredMediaItems.value, [{ url: '/uploads/a.png' }])
picker.setMediaPickerPage(99)
assert.equal(picker.mediaPickerPage.value, 3)
})
test('관리자 Markdown 미디어 선택 모달은 단일·갤러리 선택을 관리한다', () => {
const picker = useAdminMarkdownMediaPicker({ uploadSizeLimits })
picker.openMediaPickerState('image')
picker.mediaItems.value = [
{ url: '/uploads/a.png' },
{ url: '/uploads/b.png' }
]
picker.toggleMediaSelection({ url: '/uploads/a.png' })
picker.toggleMediaSelection({ url: '/uploads/b.png' })
assert.deepEqual(picker.selectedMediaUrls.value, ['/uploads/b.png'])
assert.deepEqual(picker.resolveCurrentSelectedMediaItems(), [{ url: '/uploads/b.png' }])
picker.openMediaPickerState('gallery')
picker.toggleMediaSelection({ url: '/uploads/a.png' })
picker.toggleMediaSelection({ url: '/uploads/b.png' })
assert.deepEqual(picker.selectedMediaUrls.value, ['/uploads/a.png', '/uploads/b.png'])
assert.equal(picker.getGallerySelectionOrder('/uploads/b.png'), 2)
assert.deepEqual(picker.mediaPickerApplyState.value, {
disabled: false,
label: '2개 삽입'
})
})
test('관리자 Markdown 미디어 선택 모달은 업로드 파일 선택 상태와 fallback 목록을 만든다', () => {
const picker = useAdminMarkdownMediaPicker({ uploadSizeLimits })
picker.openMediaPickerState('image')
picker.selectUploadedMediaFiles([
{ url: '/uploads/a.png', kind: 'image' },
{ url: '/uploads/a.mp4', kind: 'video' }
])
assert.deepEqual(picker.selectedMediaUrls.value, ['/uploads/a.png'])
picker.prependUploadedMediaItems([{ url: '/uploads/a.png' }])
assert.deepEqual(picker.mediaItems.value, [{ url: '/uploads/a.png' }])
assert.equal(picker.isUploadFileAllowedForPicker({ type: 'image/png' }), true)
assert.equal(picker.isUploadFileAllowedForPicker({ type: 'video/mp4' }), false)
})