import { getPostBodyPreviewThumbnailUrl, isPageCoverThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl, withGeneratedImageCacheVersion } from './post-card-thumbnail-url.js' import { serializeImageMarkdown } from './markdown-image.js' import { formatUploadSizeLimit, getMaxUploadBytesForKind, getUploadFilesTotalSize, getUploadKind } from './upload-size-limit.js' /** * 파일 크기를 표시용 문자열로 변환한다. * @param {number} size - 바이트 크기 * @returns {string} 표시용 크기 */ export const formatMediaFileSize = (size) => { const value = Number(size || 0) if (!Number.isFinite(value) || value <= 0) { return '' } if (value < 1024) { return `${value} B` } if (value < 1024 * 1024) { return `${Math.round(value / 1024)} KB` } return `${(value / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB` } /** * 미디어 항목 종류를 반환한다. * @param {Object} item - 미디어 항목 * @returns {'image'|'video'|'audio'|'file'} 미디어 종류 */ export const getMediaItemKind = (item) => { if (item?.kind) { return item.kind } const source = `${item?.extension || ''} ${item?.url || ''}`.toLowerCase() if (/\.(jpe?g|png|webp|gif)(\?|$)/i.test(source)) { return 'image' } if (/\.(mp4|webm|mov)(\?|$)/i.test(source)) { return 'video' } if (/\.(mp3|wav|ogg|m4a)(\?|$)/i.test(source)) { return 'audio' } return 'file' } /** * 미디어 항목이 현재 선택 대상과 맞는지 확인한다. * @param {Object} item - 미디어 항목 * @param {string} target - 선택 대상 * @returns {boolean} 선택 가능 여부 */ export const isMediaItemSelectableForTarget = (item, target) => { if (isPostCardThumbnailUrl(item?.url) || isPostBodyThumbnailUrl(item?.url) || isPageCoverThumbnailUrl(item?.url)) { return false } const kind = getMediaItemKind(item) if (['image', 'gallery', 'active-gallery'].includes(target)) { return kind === 'image' } return kind === target } /** * 미디어 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다. * 선택·삽입 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다. * @param {Object} item - 미디어 항목 * @returns {string} 미리보기 URL */ export const getMediaPickerPreviewUrl = (item) => { const url = String(item?.url || '') if (getMediaItemKind(item) !== 'image') { return url } if (item?.bodyThumbnailStatus?.hasThumbnail) { return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`) } if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) { return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`) } return url } /** * 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다. * @param {{ type?: string }} file - 브라우저 파일 * @param {string} target - 선택 대상 * @returns {boolean} 허용 여부 */ export const isUploadFileAllowedForMediaTarget = (file, target) => { const type = String(file?.type || '') if (['image', 'gallery', 'active-gallery'].includes(target)) { return type.startsWith('image/') } if (target === 'video') { return type.startsWith('video/') } if (target === 'audio') { return type.startsWith('audio/') } return !type.startsWith('image/') && !type.startsWith('video/') && !type.startsWith('audio/') } /** * 미디어 선택 대상이 갤러리 다중 선택인지 반환한다. * @param {string} target - 선택 대상 * @returns {boolean} 갤러리 다중 선택 여부 */ export const isGalleryMediaPickerTarget = (target) => target === 'gallery' || target === 'active-gallery' /** * 미디어 선택 대상에 맞는 파일 입력 accept 값을 반환한다. * @param {string} target - 선택 대상 * @returns {string} 파일 입력 accept 값 */ export const getMediaPickerAcceptForTarget = (target) => { if (['image', 'gallery', 'active-gallery'].includes(target)) { return 'image/*' } if (target === 'video') { return 'video/*' } if (target === 'audio') { return 'audio/*' } return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx' } /** * 미디어 선택 대상에 맞는 미디어 API kind 값을 반환한다. * @param {string} target - 선택 대상 * @returns {string} API kind 값 */ export const getMediaPickerKindForTarget = (target) => { if (['image', 'gallery', 'active-gallery'].includes(target)) { return 'image' } if (['video', 'audio', 'file'].includes(target)) { return target } return 'all' } /** * 미디어 선택 대상에 맞는 모달 제목을 반환한다. * @param {string} target - 선택 대상 * @returns {string} 모달 제목 */ export const getMediaPickerTitleForTarget = (target) => { if (isGalleryMediaPickerTarget(target)) { return '갤러리 이미지 선택' } if (target === 'video') { return '비디오 선택' } if (target === 'audio') { return '오디오 선택' } if (target === 'file') { return '파일 선택' } return '이미지 선택' } /** * 미디어 선택 대상별 업로드 안내 문구를 만든다. * @param {string} target - 선택 대상 * @param {{ image: number, video: number, audio: number, document: number }} uploadSizeLimits - 종류별 업로드 한도 * @returns {string} 업로드 안내 문구 */ export const buildMediaPickerUploadHint = (target, uploadSizeLimits) => { if (isGalleryMediaPickerTarget(target)) { return `여러 이미지를 한 번에 선택할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.image)})` } if (target === 'video') { return `MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.video)})` } if (target === 'audio') { return `MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.audio)})` } if (target === 'file') { return `PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.document)})` } return `단일 이미지가 본문에 삽입됩니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.image)})` } /** * 미디어 선택 라이브러리 빈 상태 문구를 만든다. * @param {string} searchQuery - 검색어 * @returns {string} 빈 상태 문구 */ export const buildMediaPickerEmptyMessage = (searchQuery) => { const normalizedQuery = String(searchQuery || '').trim() if (normalizedQuery) { return `「${normalizedQuery}」에 맞는 미디어가 없습니다.` } return '선택할 미디어가 없습니다. 업로드 탭에서 파일을 추가할 수 있습니다.' } /** * 미디어 선택 적용 버튼 상태를 만든다. * @param {Object} options - 적용 버튼 상태 옵션 * @param {string[]} options.selectedUrls - 선택된 URL 목록 * @param {boolean} options.isUploading - 업로드 중 여부 * @param {boolean} options.isGallery - 갤러리 다중 선택 여부 * @returns {{ disabled: boolean, label: string }} 적용 버튼 상태 */ export const buildMediaPickerApplyState = ({ selectedUrls, isUploading, isGallery }) => { const selectedCount = Array.isArray(selectedUrls) ? selectedUrls.length : 0 return { disabled: Boolean(isUploading) || selectedCount <= 0, label: isGallery && selectedCount > 0 ? `${selectedCount}개 삽입` : '삽입' } } /** * 미디어 선택 모달의 목록 조회 query를 만든다. * @param {Object} options - 조회 옵션 * @param {number} options.page - 페이지 번호 * @param {number} options.pageSize - 페이지 크기 * @param {string} options.kind - 미디어 종류 * @param {string} options.searchQuery - 검색어 * @returns {Record} 미디어 API query */ export const createMediaPickerFetchQuery = ({ page, pageSize, kind, searchQuery }) => { const normalizedSearchQuery = String(searchQuery || '').trim() return { paged: '1', tab: 'library', page, pageSize, kind, search: normalizedSearchQuery || undefined } } /** * 미디어 항목으로 Prose 미디어 블록을 만든다. * @param {'video'|'audio'|'file'} blockType - 블록 유형 * @param {Object} item - 미디어 항목 * @returns {string[]} 마크다운 줄 */ export const createMediaBlockMarkdown = (blockType, item) => { const title = item.title || item.name || '' if (blockType === 'video') { return [':::video', `url=${item.url}`, `title=${title}`, 'caption=', ':::'] } if (blockType === 'audio') { return [':::audio', `url=${item.url}`, `title=${title}`, 'description=', ':::'] } return [ ':::file', `url=${item.url}`, `title=${item.name || title}`, 'description=', `name=${item.name || title}`, `size=${formatMediaFileSize(item.size)}`, ':::' ] } /** * 미디어 항목을 이미지 삽입용 데이터로 변환한다. * @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 {FileList|Array|null|undefined} files - 업로드 입력 파일 목록 * @returns {File[]} 파일 배열 */ export const normalizeMediaUploadFiles = (files) => { return Array.from(files || []).filter(Boolean) } /** * 업로드 파일 목록으로 multipart FormData를 만든다. * @param {FileList|Array} files - 업로드 파일 목록 * @returns {FormData} 업로드 요청 본문 */ export const createMediaUploadFormData = (files) => { const formData = new FormData() normalizeMediaUploadFiles(files).forEach((file) => { formData.append('files', file) }) return formData } /** * 업로드 API 응답에서 파일 목록을 추출한다. * @param {Object|null|undefined} response - 업로드 API 응답 * @returns {Object[]} 업로드된 파일 목록 */ export const resolveUploadedMediaFiles = (response) => { return Array.isArray(response?.files) ? response.files : [] } /** * 이미지 파일 목록에 맞는 기본 삽입 대상을 반환한다. * @param {FileList|Array} files - 이미지 파일 목록 * @returns {'image'|'gallery'} 삽입 대상 */ export const resolveImageUploadInsertTarget = (files) => { return normalizeMediaUploadFiles(files).length > 1 ? 'gallery' : 'image' } /** * 파일 목록에서 이미지 파일만 반환한다. * @param {FileList|Array} files - 입력 파일 목록 * @returns {File[]} 이미지 파일 목록 */ export const filterImageUploadFiles = (files) => { return normalizeMediaUploadFiles(files).filter((file) => String(file?.type || '').startsWith('image/')) } /** * 갤러리 미디어 선택 순서를 반환한다. * @param {string[]} selectedUrls - 선택된 URL 목록 * @param {string} url - 미디어 URL * @returns {number} 1부터 시작하는 선택 순서, 선택되지 않았으면 0 */ export const getGallerySelectionOrder = (selectedUrls, url) => { const index = Array.isArray(selectedUrls) ? selectedUrls.indexOf(url) : -1 return index >= 0 ? index + 1 : 0 } /** * 미디어 선택 상태를 토글한다. * @param {Object} options - 토글 옵션 * @param {string[]} options.selectedUrls - 선택된 URL 목록 * @param {Record} options.selectedItemMap - 선택된 미디어 항목 맵 * @param {Object} options.mediaItem - 토글 대상 미디어 항목 * @param {boolean} options.isGallery - 갤러리 다중 선택 여부 * @returns {{ selectedUrls: string[], selectedItemMap: Record }} 다음 선택 상태 */ export const toggleMediaSelectionState = ({ selectedUrls, selectedItemMap, mediaItem, isGallery }) => { const url = mediaItem?.url if (!url) { return { selectedUrls: Array.isArray(selectedUrls) ? selectedUrls : [], selectedItemMap: selectedItemMap && typeof selectedItemMap === 'object' ? selectedItemMap : {} } } if (!isGallery) { return { selectedUrls: [url], selectedItemMap: { [url]: mediaItem } } } const currentUrls = Array.isArray(selectedUrls) ? selectedUrls : [] const currentMap = selectedItemMap && typeof selectedItemMap === 'object' ? selectedItemMap : {} if (currentUrls.includes(url)) { const nextMap = { ...currentMap } delete nextMap[url] return { selectedUrls: currentUrls.filter((selectedUrl) => selectedUrl !== url), selectedItemMap: nextMap } } return { selectedUrls: [...currentUrls, url], selectedItemMap: { ...currentMap, [url]: mediaItem } } } /** * 선택된 URL 순서에 맞춰 미디어 항목 목록을 반환한다. * @param {string[]} selectedUrls - 선택된 URL 목록 * @param {Record} selectedItemMap - 선택된 미디어 항목 맵 * @param {Object[]} mediaItems - 현재 미디어 목록 * @returns {Object[]} 선택된 미디어 항목 목록 */ export const resolveSelectedMediaItems = (selectedUrls, selectedItemMap, mediaItems) => { const itemMap = selectedItemMap && typeof selectedItemMap === 'object' ? selectedItemMap : {} const items = Array.isArray(mediaItems) ? mediaItems : [] return (Array.isArray(selectedUrls) ? selectedUrls : []) .map((url) => itemMap[url] || items.find((item) => item?.url === url)) .filter(Boolean) } /** * 업로드 API 오류 메시지를 사용자용 문장으로 변환한다. * @param {unknown} error - fetch 오류 * @returns {string} 오류 메시지 */ export const resolveUploadFetchErrorMessage = (error) => { const responseMessage = error?.data?.message || error?.response?._data?.message if (typeof responseMessage === 'string' && responseMessage.trim()) { return responseMessage.trim() } if (error?.statusCode === 413 || error?.status === 413) { return '업로드 가능한 파일 크기를 초과했습니다.' } if (error instanceof Error && error.message) { return error.message } return '파일 업로드에 실패했습니다.' } /** * 선택 파일이 업로드 한도를 넘지 않는지 검사한다. * @param {Object} options - 업로드 한도 검사 옵션 * @param {FileList|Array} options.files - 업로드 파일 목록 * @param {number} options.maxFileCount - 최대 파일 수 * @param {number} options.maxTotalSize - 최대 전체 용량 * @param {{ image: number, video: number, audio: number, document: number }} options.uploadSizeLimits - 종류별 업로드 한도 * @returns {string|null} 초과 시 오류 메시지, 아니면 null */ export const findUploadSizeLimitError = ({ files, maxFileCount, maxTotalSize, uploadSizeLimits }) => { const fileList = Array.from(files || []) if (fileList.length > maxFileCount) { return `한 번에 업로드할 수 있는 파일은 최대 ${maxFileCount}개입니다.` } const uploadTotalSize = getUploadFilesTotalSize(fileList) if (uploadTotalSize > maxTotalSize) { return `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(maxTotalSize)})` } for (const file of fileList) { const uploadKind = getUploadKind(file.type, file.name) const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits) if (file.size > maxBytes) { return `파일 크기가 너무 큽니다. (최대 ${formatUploadSizeLimit(maxBytes)})` } } return null } /** * 미디어 선택 페이지 번호를 유효 범위로 보정한다. * @param {number|string} page - 요청 페이지 * @param {number|string} pageCount - 전체 페이지 수 * @returns {number} 보정된 페이지 */ export const normalizeMediaPickerPage = (page, pageCount) => { const total = Math.max(1, Number(pageCount) || 1) return Math.min(Math.max(Number(page) || 1, 1), total) } /** * 미디어 선택 모달의 페이지 범위 표시 문구를 만든다. * @param {Object} options - 범위 표시 옵션 * @param {Object} options.pagination - 서버 페이지네이션 응답 * @param {number} options.currentPage - 현재 페이지 fallback * @param {number} options.pageCount - 전체 페이지 수 * @param {number} options.defaultPageSize - 기본 페이지 크기 * @returns {string} 범위 표시 문구 */ export const buildMediaPickerRangeLabel = ({ pagination, currentPage, pageCount, defaultPageSize }) => { const total = Number(pagination?.total) || 0 if (total <= 0) { return '0개' } const page = normalizeMediaPickerPage(Number(pagination?.page) || currentPage, pageCount) const pageSize = Number(pagination?.pageSize) || defaultPageSize const start = ((page - 1) * pageSize) + 1 const end = Math.min(start + pageSize - 1, total) return `${total}개 중 ${start}-${end}개` } /** * 미디어 선택 모달 페이지네이션 항목을 만든다. * @param {Object} options - 페이지 항목 옵션 * @param {number} options.currentPage - 현재 페이지 * @param {number} options.pageCount - 전체 페이지 수 * @returns {Array} 페이지 번호와 ellipsis 키 목록 */ export const buildMediaPickerPageItems = ({ currentPage, pageCount }) => { const total = Math.max(1, Number(pageCount) || 1) const current = normalizeMediaPickerPage(currentPage, total) const pages = new Set([1, total, current]) for (let page = current - 2; page <= current + 2; page += 1) { pages.add(page) } if (current <= 3) { pages.add(2) pages.add(3) pages.add(4) } if (current >= total - 2) { pages.add(total - 3) pages.add(total - 2) pages.add(total - 1) } const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right) return orderedPages.reduce((items, page, index) => { const previousPage = orderedPages[index - 1] if (previousPage && page - previousPage > 1) { items.push(`ellipsis-${previousPage}-${page}`) } items.push(page) return items }, []) } /** * 미디어 선택 모달의 기본 페이지네이션 상태를 만든다. * @param {number} pageSize - 기본 페이지 크기 * @param {number} [total=0] - 전체 항목 수 * @returns {{ page: number, pageSize: number, total: number, pageCount: number }} 페이지네이션 상태 */ export const createDefaultMediaPickerPagination = (pageSize, total = 0) => ({ page: 1, pageSize, total, pageCount: 1 }) /** * 미디어 선택 API 응답을 에디터 상태용 목록과 페이지네이션으로 정규화한다. * @param {Object|null|undefined} response - 미디어 API 응답 * @param {number} pageSize - 기본 페이지 크기 * @returns {{ items: Object[], pagination: { page: number, pageSize: number, total: number, pageCount: number } }} 정규화된 응답 */ export const normalizeMediaPickerResponse = (response, pageSize) => { const items = Array.isArray(response?.items) ? response.items : [] const pagination = response?.pagination && typeof response.pagination === 'object' ? response.pagination : createDefaultMediaPickerPagination(pageSize, items.length) return { items, pagination: { page: Math.max(1, Number(pagination.page) || 1), pageSize: Math.max(1, Number(pagination.pageSize) || pageSize), total: Math.max(0, Number(pagination.total) || 0), pageCount: Math.max(1, Number(pagination.pageCount) || 1) } } }