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

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 - 붙여넣기 이벤트