diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index bc72fc1..33eadf1 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -22,14 +22,18 @@ import { getEditorHistoryEntryValue } from '../../lib/markdown-editor-history.js' import { + buildMediaPickerUploadHint, buildMediaPickerPageItems, buildMediaPickerRangeLabel, createMediaBlockMarkdown, createDefaultMediaPickerPagination, findUploadSizeLimitError, + getMediaPickerAcceptForTarget, + getMediaPickerKindForTarget, getGallerySelectionOrder as getGallerySelectionOrderFromSelection, getMediaItemKind, getMediaPickerPreviewUrl, + isGalleryMediaPickerTarget, isMediaItemSelectableForTarget, isUploadFileAllowedForMediaTarget, normalizeMediaPickerResponse, @@ -54,7 +58,6 @@ import { buildDefaultUploadSizeLimits, DEFAULT_UPLOAD_MAX_FILE_COUNT, DEFAULT_UPLOAD_MAX_TOTAL_SIZE, - formatUploadSizeLimit, normalizeUploadMaxFileCount, normalizeUploadMaxTotalSize } from '../../lib/upload-size-limit.js' @@ -164,33 +167,9 @@ const editorRedoStack = ref([]) const isRestoringEditorHistory = ref(false) /** 마지막 히스토리 기록 시각 */ let lastEditorHistoryRecordAt = 0 -const mediaPickerAccept = computed(() => { - if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) { - return 'image/*' - } +const mediaPickerAccept = computed(() => getMediaPickerAcceptForTarget(mediaPickerTarget.value)) - if (mediaPickerTarget.value === 'video') { - return 'video/*' - } - - if (mediaPickerTarget.value === 'audio') { - return 'audio/*' - } - - return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx' -}) - -const mediaPickerKind = computed(() => { - if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) { - return 'image' - } - - if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) { - return mediaPickerTarget.value - } - - return 'all' -}) +const mediaPickerKind = computed(() => getMediaPickerKindForTarget(mediaPickerTarget.value)) /** 작성 textarea 최소 높이(px) */ const MIN_TEXTAREA_HEIGHT_PX = 620 @@ -3393,32 +3372,16 @@ const mediaPickerTitle = computed(() => { * 미디어 모달에서 다중 선택 여부를 반환한다. * @returns {boolean} 다중 선택 여부 */ -const isGalleryMediaPicker = computed(() => mediaPickerTarget.value === 'gallery' - || mediaPickerTarget.value === 'active-gallery') +const isGalleryMediaPicker = computed(() => isGalleryMediaPickerTarget(mediaPickerTarget.value)) /** * 미디어 업로드 안내 문구를 반환한다. * @returns {string} 안내 문구 */ -const mediaPickerUploadHint = computed(() => { - if (isGalleryMediaPicker.value) { - return `여러 이미지를 한 번에 선택할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})` - } - - if (mediaPickerTarget.value === 'video') { - return `MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.video)})` - } - - if (mediaPickerTarget.value === 'audio') { - return `MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.audio)})` - } - - if (mediaPickerTarget.value === 'file') { - return `PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.document)})` - } - - return `단일 이미지가 본문에 삽입됩니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})` -}) +const mediaPickerUploadHint = computed(() => buildMediaPickerUploadHint( + mediaPickerTarget.value, + uploadSizeLimits.value +)) /** * 붙여넣은 이미지 파일을 업로드한다. diff --git a/docs/map.md b/docs/map.md index 67ad1c8..8d56564 100644 --- a/docs/map.md +++ b/docs/map.md @@ -48,7 +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-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 대상별 accept·API kind·업로드 안내, 선택 순서·토글·항목 해석, 업로드 오류·한도 검증, 페이지 상태·범위·페이지 번호 계산, 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` 권한으로 정규화 | diff --git a/docs/update.md b/docs/update.md index 3f1d00a..60c4d14 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.11.74 + +- 관리자 Markdown 에디터 미디어 선택 대상별 accept·API kind·다중 선택 여부·업로드 안내 문구 계산을 공통 helper로 분리. +- 미디어 선택 대상별 입력·안내 계산 단위 테스트 추가. + ## v1.11.73 - 관리자 Markdown 에디터 미디어 선택 모달의 기본 페이지네이션 상태 생성과 API 응답 정규화 로직을 공통 helper로 분리. diff --git a/lib/markdown-editor-media.js b/lib/markdown-editor-media.js index 9fae33f..7a72400 100644 --- a/lib/markdown-editor-media.js +++ b/lib/markdown-editor-media.js @@ -130,6 +130,77 @@ export const isUploadFileAllowedForMediaTarget = (file, target) => { 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 - 선택 대상 + * @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)})` +} + /** * 미디어 항목으로 Prose 미디어 블록을 만든다. * @param {'video'|'audio'|'file'} blockType - 블록 유형 diff --git a/package-lock.json b/package-lock.json index 93ece9d..6bc3151 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.73", + "version": "1.11.74", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.73", + "version": "1.11.74", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 31a6703..f157f62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.73", + "version": "1.11.74", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-editor-media.test.js b/tests/markdown-editor-media.test.js index 7dcd446..2c32359 100644 --- a/tests/markdown-editor-media.test.js +++ b/tests/markdown-editor-media.test.js @@ -3,13 +3,17 @@ import test from 'node:test' import { buildMediaPickerPageItems, buildMediaPickerRangeLabel, + buildMediaPickerUploadHint, createMediaBlockMarkdown, createDefaultMediaPickerPagination, findUploadSizeLimitError, formatMediaFileSize, getGallerySelectionOrder, getMediaItemKind, + getMediaPickerAcceptForTarget, + getMediaPickerKindForTarget, getMediaPickerPreviewUrl, + isGalleryMediaPickerTarget, isMediaItemSelectableForTarget, isUploadFileAllowedForMediaTarget, normalizeMediaPickerResponse, @@ -60,6 +64,38 @@ test('업로드 파일은 선택 대상별 MIME 타입으로 허용 여부를 assert.equal(isUploadFileAllowedForMediaTarget({ type: 'image/png' }, 'file'), false) }) +test('미디어 선택 대상별 입력 accept와 API kind를 계산한다', () => { + assert.equal(getMediaPickerAcceptForTarget('image'), 'image/*') + assert.equal(getMediaPickerAcceptForTarget('gallery'), 'image/*') + assert.equal(getMediaPickerAcceptForTarget('video'), 'video/*') + assert.equal(getMediaPickerAcceptForTarget('audio'), 'audio/*') + assert.equal(getMediaPickerAcceptForTarget('file'), '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx') + + assert.equal(getMediaPickerKindForTarget('image'), 'image') + assert.equal(getMediaPickerKindForTarget('active-gallery'), 'image') + assert.equal(getMediaPickerKindForTarget('video'), 'video') + assert.equal(getMediaPickerKindForTarget('file'), 'file') + assert.equal(getMediaPickerKindForTarget('unknown'), 'all') +}) + +test('미디어 선택 대상별 갤러리 여부와 업로드 안내 문구를 계산한다', () => { + const uploadSizeLimits = buildDefaultUploadSizeLimits({ + image: 10485760, + video: 209715200, + audio: 52428800, + document: 52428800 + }) + + assert.equal(isGalleryMediaPickerTarget('gallery'), true) + assert.equal(isGalleryMediaPickerTarget('active-gallery'), true) + assert.equal(isGalleryMediaPickerTarget('image'), false) + assert.equal(buildMediaPickerUploadHint('gallery', uploadSizeLimits), '여러 이미지를 한 번에 선택할 수 있습니다. (최대 10MB)') + assert.equal(buildMediaPickerUploadHint('video', uploadSizeLimits), 'MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 200MB)') + assert.equal(buildMediaPickerUploadHint('audio', uploadSizeLimits), 'MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 50MB)') + assert.equal(buildMediaPickerUploadHint('file', uploadSizeLimits), 'PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 50MB)') + assert.equal(buildMediaPickerUploadHint('image', uploadSizeLimits), '단일 이미지가 본문에 삽입됩니다. (최대 10MB)') +}) + test('미디어 항목으로 fenced 미디어 블록을 만든다', () => { assert.deepEqual(createMediaBlockMarkdown('video', { url: '/uploads/a.mp4',