import { getPostBodyPreviewThumbnailUrl, isPageCoverThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl, withGeneratedImageCacheVersion } from './post-card-thumbnail-url.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/') } /** * 미디어 항목으로 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 {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) }