diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 5ed5da0..a96d133 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -21,6 +21,13 @@ import { getEditorHistoryEntrySelection, getEditorHistoryEntryValue } from '../../lib/markdown-editor-history.js' +import { + createMediaBlockMarkdown, + getMediaItemKind, + getMediaPickerPreviewUrl, + isMediaItemSelectableForTarget, + isUploadFileAllowedForMediaTarget +} from '../../lib/markdown-editor-media.js' import { filterSlashCommands, getSlashCommandFocusTarget, @@ -33,13 +40,6 @@ import { import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js' import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js' import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js' -import { - getPostBodyPreviewThumbnailUrl, - isPageCoverThumbnailUrl, - isPostBodyThumbnailUrl, - isPostCardThumbnailUrl, - withGeneratedImageCacheVersion -} from '../../lib/post-card-thumbnail-url.js' import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js' import { buildDefaultUploadSizeLimits, @@ -1788,98 +1788,12 @@ const parseEditorImageLine = (line) => { } } -/** - * 파일 크기를 표시용 문자열로 변환한다. - * @param {number} size - 바이트 크기 - * @returns {string} 표시용 크기 - */ -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'} 미디어 종류 - */ -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 - 미디어 항목 * @returns {boolean} 선택 가능 여부 */ -const isMediaItemSelectableForTarget = (item) => { - if (isPostCardThumbnailUrl(item?.url) || isPostBodyThumbnailUrl(item?.url) || isPageCoverThumbnailUrl(item?.url)) { - return false - } - - const kind = getMediaItemKind(item) - - if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) { - return kind === 'image' - } - - return kind === mediaPickerTarget.value -} - -/** - * 미디어 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다. - * 선택·삽입 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다. - * @param {Object} item - 미디어 항목 - * @returns {string} 미리보기 URL - */ -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 -} +const isMediaItemSelectableForCurrentTarget = (item) => isMediaItemSelectableForTarget(item, mediaPickerTarget.value) /** * 미디어 선택 모달 파생 이미지 로드 실패 시 원본 URL로 되돌린다. @@ -1904,47 +1818,7 @@ const onMediaPickerImageError = (event, item) => { * @returns {boolean} 허용 여부 */ const isUploadFileAllowedForPicker = (file) => { - if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) { - return file.type.startsWith('image/') - } - - if (mediaPickerTarget.value === 'video') { - return file.type.startsWith('video/') - } - - if (mediaPickerTarget.value === 'audio') { - return file.type.startsWith('audio/') - } - - return !file.type.startsWith('image/') && !file.type.startsWith('video/') && !file.type.startsWith('audio/') -} - -/** - * 미디어 항목으로 Prose 미디어 블록을 만든다. - * @param {'video'|'audio'|'file'} blockType - 블록 유형 - * @param {Object} item - 미디어 항목 - * @returns {string[]} 마크다운 줄 - */ -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)}`, - ':::' - ] + return isUploadFileAllowedForMediaTarget(file, mediaPickerTarget.value) } /** @@ -3194,7 +3068,7 @@ const closeMediaPicker = () => { * 현재 미디어 선택 페이지의 표시 목록 * @returns {Array} 표시할 미디어 목록 */ -const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForTarget)) +const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForCurrentTarget)) const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1)) @@ -3545,7 +3419,7 @@ const uploadFromMediaModal = async (files) => { try { const uploadedFiles = await uploadMediaFiles(files) const uploadedUrls = uploadedFiles - .filter(isMediaItemSelectableForTarget) + .filter(isMediaItemSelectableForCurrentTarget) .map((file) => file.url) selectedMediaItemMap.value = uploadedFiles.reduce((map, file) => { if (uploadedUrls.includes(file.url)) { diff --git a/docs/map.md b/docs/map.md index d5b1f02..b9f156d 100644 --- a/docs/map.md +++ b/docs/map.md @@ -48,6 +48,7 @@ | lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` | | lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 | | lib/markdown-editor-history.js | 관리자 Markdown-first 에디터 undo/redo 히스토리 항목 생성, 레거시 문자열 항목 본문 추출, 선택 범위 보정 | +| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, fenced 미디어 블록 생성 | | lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 | | lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 | | lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출, `allowfullscreen`을 `allow=fullscreen` 권한으로 정규화 | @@ -119,6 +120,7 @@ | tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 | | tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 | | tests/markdown-editor-history.test.js | 관리자 Markdown 에디터 undo/redo 히스토리 항목과 선택 범위 보정 단위 회귀 테스트 | +| tests/markdown-editor-media.test.js | 관리자 Markdown 에디터 미디어 선택 helper의 종류·선택 가능 여부·미리보기·블록 생성 단위 회귀 테스트 | | tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·포커스 대상·원문 줄 교체 삽입 단위 회귀 테스트 | | tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 | | tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 | diff --git a/docs/update.md b/docs/update.md index 22d0a89..7c80b76 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.11.69 + +- 관리자 Markdown 에디터의 미디어 선택 모달 종류 판별·대상별 선택 가능 여부·미리보기 URL·업로드 MIME 판별·미디어 블록 생성 로직을 공통 helper로 분리. +- 미디어 선택 helper의 파일 크기 표시·종류 판별·파생 썸네일 제외·fenced 미디어 블록 생성 단위 테스트 추가. + ## v1.11.68 - 관리자 Markdown 에디터의 히스토리 항목 생성·본문 값 추출·선택 범위 보정 로직을 공통 helper로 분리. diff --git a/lib/markdown-editor-media.js b/lib/markdown-editor-media.js new file mode 100644 index 0000000..08d9010 --- /dev/null +++ b/lib/markdown-editor-media.js @@ -0,0 +1,153 @@ +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)}`, + ':::' + ] +} diff --git a/package-lock.json b/package-lock.json index ffd4300..5fe2441 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.68", + "version": "1.11.69", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.68", + "version": "1.11.69", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index b9bee16..0c9b1e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.68", + "version": "1.11.69", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-editor-media.test.js b/tests/markdown-editor-media.test.js new file mode 100644 index 0000000..0edcbd5 --- /dev/null +++ b/tests/markdown-editor-media.test.js @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + createMediaBlockMarkdown, + formatMediaFileSize, + getMediaItemKind, + getMediaPickerPreviewUrl, + isMediaItemSelectableForTarget, + isUploadFileAllowedForMediaTarget +} from '../lib/markdown-editor-media.js' + +test('미디어 파일 크기를 표시 문자열로 변환한다', () => { + assert.equal(formatMediaFileSize(512), '512 B') + assert.equal(formatMediaFileSize(1536), '2 KB') + assert.equal(formatMediaFileSize(1024 * 1024 * 1.5), '1.5 MB') + assert.equal(formatMediaFileSize(0), '') +}) + +test('미디어 항목 kind가 없으면 확장자와 URL로 종류를 판별한다', () => { + assert.equal(getMediaItemKind({ url: '/uploads/a.webp' }), 'image') + assert.equal(getMediaItemKind({ url: '/uploads/a.mp4?x=1' }), 'video') + assert.equal(getMediaItemKind({ url: '/uploads/a.m4a' }), 'audio') + assert.equal(getMediaItemKind({ url: '/uploads/a.zip' }), 'file') +}) + +test('선택 대상별로 허용 미디어를 판별하고 파생 썸네일은 제외한다', () => { + assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/a.png' }, 'image'), true) + assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/a.mp4' }, 'image'), false) + assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/a.mp4' }, 'video'), true) + assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/thumbs/a-card.webp' }, 'image'), false) + assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/thumbs/a-body-720.webp' }, 'gallery'), false) +}) + +test('미디어 선택 미리보기는 본문 파생 썸네일을 우선한다', () => { + assert.equal(getMediaPickerPreviewUrl({ + url: '/uploads/posts/2026/07/a.png', + updatedAt: '2026-07-24T00:00:00.000Z', + size: 100, + bodyThumbnailStatus: { + hasThumbnail: true + } + }), '/uploads/posts/2026/07/thumbs/a-body-720.webp?v=2026-07-24T00%3A00%3A00.000Z-100') +}) + +test('업로드 파일은 선택 대상별 MIME 타입으로 허용 여부를 판별한다', () => { + assert.equal(isUploadFileAllowedForMediaTarget({ type: 'image/png' }, 'gallery'), true) + assert.equal(isUploadFileAllowedForMediaTarget({ type: 'video/mp4' }, 'video'), true) + assert.equal(isUploadFileAllowedForMediaTarget({ type: 'audio/mpeg' }, 'audio'), true) + assert.equal(isUploadFileAllowedForMediaTarget({ type: 'application/pdf' }, 'file'), true) + assert.equal(isUploadFileAllowedForMediaTarget({ type: 'image/png' }, 'file'), false) +}) + +test('미디어 항목으로 fenced 미디어 블록을 만든다', () => { + assert.deepEqual(createMediaBlockMarkdown('video', { + url: '/uploads/a.mp4', + title: '영상' + }), [':::video', 'url=/uploads/a.mp4', 'title=영상', 'caption=', ':::']) + + assert.deepEqual(createMediaBlockMarkdown('file', { + url: '/uploads/a.pdf', + name: 'a.pdf', + size: 2048 + }), [':::file', 'url=/uploads/a.pdf', 'title=a.pdf', 'description=', 'name=a.pdf', 'size=2 KB', ':::']) +})