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

725 lines
22 KiB
JavaScript

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<string, string|number|undefined>} 미디어 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<File>|null|undefined} files - 업로드 입력 파일 목록
* @returns {File[]} 파일 배열
*/
export const normalizeMediaUploadFiles = (files) => {
return Array.from(files || []).filter(Boolean)
}
/**
* 업로드 파일 목록으로 multipart FormData를 만든다.
* @param {FileList|Array<File>} 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<File>} files - 이미지 파일 목록
* @returns {'image'|'gallery'} 삽입 대상
*/
export const resolveImageUploadInsertTarget = (files) => {
return normalizeMediaUploadFiles(files).length > 1 ? 'gallery' : 'image'
}
/**
* 파일 목록에서 이미지 파일만 반환한다.
* @param {FileList|Array<File>} 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<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)
}
/**
* 업로드 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<File>} 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<number|string>} 페이지 번호와 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)
}
}
}