Files
sori.studio/lib/markdown-editor-media.js

236 lines
6.7 KiB
JavaScript

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<string, Object>} options.selectedItemMap - 선택된 미디어 항목 맵
* @param {Object} options.mediaItem - 토글 대상 미디어 항목
* @param {boolean} options.isGallery - 갤러리 다중 선택 여부
* @returns {{ selectedUrls: string[], selectedItemMap: Record<string, Object> }} 다음 선택 상태
*/
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<string, Object>} 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)
}