관리자 미디어 선택 상태 분리
This commit is contained in:
@@ -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 - 붙여넣기 이벤트
|
||||
|
||||
Reference in New Issue
Block a user