From 6cc820d2a23564d9c8d88c583c73073db45d26a0 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 24 Jul 2026 17:51:02 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=20=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminMarkdownEditor.vue | 44 ++++++------- docs/map.md | 4 +- docs/update.md | 5 ++ lib/markdown-editor-media.js | 82 ++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- tests/markdown-editor-media.test.js | 67 ++++++++++++++++++- 7 files changed, 176 insertions(+), 32 deletions(-) diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index a96d133..7505c71 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -23,10 +23,13 @@ import { } from '../../lib/markdown-editor-history.js' import { createMediaBlockMarkdown, + getGallerySelectionOrder as getGallerySelectionOrderFromSelection, getMediaItemKind, getMediaPickerPreviewUrl, isMediaItemSelectableForTarget, - isUploadFileAllowedForMediaTarget + isUploadFileAllowedForMediaTarget, + resolveSelectedMediaItems, + toggleMediaSelectionState } from '../../lib/markdown-editor-media.js' import { filterSlashCommands, @@ -3131,8 +3134,7 @@ const getGallerySelectionOrder = (url) => { return 0 } - const index = selectedMediaUrls.value.indexOf(url) - return index >= 0 ? index + 1 : 0 + return getGallerySelectionOrderFromSelection(selectedMediaUrls.value, url) } /** @@ -3169,27 +3171,15 @@ watch(activeMediaPickerTab, async (tab) => { * @returns {void} */ const toggleMediaSelection = (mediaItem) => { - if (!isGalleryMediaPicker.value) { - selectedMediaUrls.value = [mediaItem.url] - selectedMediaItemMap.value = { - [mediaItem.url]: mediaItem - } - return - } + const nextSelection = toggleMediaSelectionState({ + selectedUrls: selectedMediaUrls.value, + selectedItemMap: selectedMediaItemMap.value, + mediaItem, + isGallery: isGalleryMediaPicker.value + }) - if (selectedMediaUrls.value.includes(mediaItem.url)) { - selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => url !== mediaItem.url) - const nextMap = { ...selectedMediaItemMap.value } - delete nextMap[mediaItem.url] - selectedMediaItemMap.value = nextMap - return - } - - selectedMediaUrls.value = [...selectedMediaUrls.value, mediaItem.url] - selectedMediaItemMap.value = { - ...selectedMediaItemMap.value, - [mediaItem.url]: mediaItem - } + selectedMediaUrls.value = nextSelection.selectedUrls + selectedMediaItemMap.value = nextSelection.selectedItemMap } /** @@ -3268,9 +3258,11 @@ const insertSelectedMediaItems = (selectedItems) => { * @returns {void} */ const applyMediaSelection = () => { - const selectedItems = selectedMediaUrls.value - .map((url) => selectedMediaItemMap.value[url] || mediaItems.value.find((item) => item.url === url)) - .filter(Boolean) + const selectedItems = resolveSelectedMediaItems( + selectedMediaUrls.value, + selectedMediaItemMap.value, + mediaItems.value + ) insertSelectedMediaItems(selectedItems) closeMediaPicker() diff --git a/docs/map.md b/docs/map.md index b9f156d..3df54e2 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 허용, 선택 순서·토글·항목 해석, 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` 권한으로 정규화 | @@ -120,7 +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-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 7c80b76..ad841f4 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.11.70 + +- 관리자 Markdown 에디터 미디어 선택 모달의 선택 순서·단일/갤러리 선택 토글·선택 URL 기반 항목 해석 로직을 공통 helper로 분리. +- 미디어 선택 상태 토글과 선택 항목 해석 단위 테스트 추가. + ## v1.11.69 - 관리자 Markdown 에디터의 미디어 선택 모달 종류 판별·대상별 선택 가능 여부·미리보기 URL·업로드 MIME 판별·미디어 블록 생성 로직을 공통 helper로 분리. diff --git a/lib/markdown-editor-media.js b/lib/markdown-editor-media.js index 08d9010..59c2a84 100644 --- a/lib/markdown-editor-media.js +++ b/lib/markdown-editor-media.js @@ -151,3 +151,85 @@ export const createMediaBlockMarkdown = (blockType, item) => { ':::' ] } + +/** + * 갤러리 미디어 선택 순서를 반환한다. + * @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) +} diff --git a/package-lock.json b/package-lock.json index 5fe2441..7b047e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.69", + "version": "1.11.70", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.69", + "version": "1.11.70", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 0c9b1e0..401a2b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.69", + "version": "1.11.70", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-editor-media.test.js b/tests/markdown-editor-media.test.js index 0edcbd5..a7895a0 100644 --- a/tests/markdown-editor-media.test.js +++ b/tests/markdown-editor-media.test.js @@ -3,10 +3,13 @@ import test from 'node:test' import { createMediaBlockMarkdown, formatMediaFileSize, + getGallerySelectionOrder, getMediaItemKind, getMediaPickerPreviewUrl, isMediaItemSelectableForTarget, - isUploadFileAllowedForMediaTarget + isUploadFileAllowedForMediaTarget, + resolveSelectedMediaItems, + toggleMediaSelectionState } from '../lib/markdown-editor-media.js' test('미디어 파일 크기를 표시 문자열로 변환한다', () => { @@ -62,3 +65,65 @@ test('미디어 항목으로 fenced 미디어 블록을 만든다', () => { size: 2048 }), [':::file', 'url=/uploads/a.pdf', 'title=a.pdf', 'description=', 'name=a.pdf', 'size=2 KB', ':::']) }) + +test('갤러리 선택 순서를 선택 URL 순서로 반환한다', () => { + assert.equal(getGallerySelectionOrder(['/a.png', '/b.png'], '/b.png'), 2) + assert.equal(getGallerySelectionOrder(['/a.png'], '/b.png'), 0) +}) + +test('단일 미디어 선택은 기존 선택을 새 항목 하나로 교체한다', () => { + assert.deepEqual(toggleMediaSelectionState({ + selectedUrls: ['/old.png'], + selectedItemMap: { + '/old.png': { url: '/old.png' } + }, + mediaItem: { url: '/new.png', name: 'new' }, + isGallery: false + }), { + selectedUrls: ['/new.png'], + selectedItemMap: { + '/new.png': { url: '/new.png', name: 'new' } + } + }) +}) + +test('갤러리 미디어 선택은 선택 추가와 제거를 토글한다', () => { + const selected = toggleMediaSelectionState({ + selectedUrls: ['/a.png'], + selectedItemMap: { + '/a.png': { url: '/a.png' } + }, + mediaItem: { url: '/b.png' }, + isGallery: true + }) + + assert.deepEqual(selected.selectedUrls, ['/a.png', '/b.png']) + assert.deepEqual(Object.keys(selected.selectedItemMap), ['/a.png', '/b.png']) + + assert.deepEqual(toggleMediaSelectionState({ + selectedUrls: selected.selectedUrls, + selectedItemMap: selected.selectedItemMap, + mediaItem: { url: '/a.png' }, + isGallery: true + }), { + selectedUrls: ['/b.png'], + selectedItemMap: { + '/b.png': { url: '/b.png' } + } + }) +}) + +test('선택된 URL 순서대로 미디어 항목을 해석한다', () => { + assert.deepEqual(resolveSelectedMediaItems( + ['/a.png', '/b.png', '/missing.png'], + { + '/a.png': { url: '/a.png', source: 'map' } + }, + [ + { url: '/b.png', source: 'list' } + ] + ), [ + { url: '/a.png', source: 'map' }, + { url: '/b.png', source: 'list' } + ]) +})