From 965a8fd1f63282183d3bbd0f1ab84910b58e76a7 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 13 May 2026 16:12:51 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=A4=EB=9F=AC=EB=A6=AC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EA=B3=BC=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminBlockEditor.vue | 232 ++++++++++++++++++++++++-- docs/history.md | 6 + docs/map.md | 2 +- docs/spec.md | 3 + docs/update.md | 7 + package-lock.json | 4 +- package.json | 2 +- 7 files changed, 236 insertions(+), 20 deletions(-) diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index d9fb9fa..204d00f 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -22,11 +22,14 @@ const isApplyingExternalValue = ref(false) const uploadingBlockIds = ref([]) const mediaItems = ref([]) const mediaPickerTarget = ref(null) +const selectedGalleryMediaUrls = ref([]) const isMediaPickerOpen = ref(false) const isLoadingMedia = ref(false) const isComposingText = ref(false) const isNormalizingTrailingBlock = ref(false) const pendingSoftLineBreakIndex = ref(-1) +const draggingGalleryImage = ref(null) +const galleryDragTarget = ref(null) let blockIdSeed = 0 const imageWidthOptions = [ @@ -419,6 +422,20 @@ const emitContent = () => { */ const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type) +/** + * 갤러리 열 개수 반환 + * @param {Object} block - 갤러리 블록 + * @returns {number} 열 개수 + */ +const getGalleryColumnCount = (block) => Math.min(Math.max(block.images.length, 1), 3) + +/** + * 갤러리 미디어 선택 여부 확인 + * @param {Object} mediaItem - 미디어 항목 + * @returns {boolean} 선택 여부 + */ +const isGalleryMediaSelected = (mediaItem) => selectedGalleryMediaUrls.value.includes(mediaItem.url) + /** * 비어 있는 문단 블록 여부 반환 * @param {Object|undefined} block - 에디터 블록 @@ -953,6 +970,9 @@ const openMediaPicker = async (block) => { blockId: block.id, type: block.type } + selectedGalleryMediaUrls.value = block.type === 'gallery' + ? block.images.map((image) => image.url).filter(Boolean) + : [] isMediaPickerOpen.value = true await fetchMediaItems() } @@ -964,6 +984,44 @@ const openMediaPicker = async (block) => { const closeMediaPicker = () => { isMediaPickerOpen.value = false mediaPickerTarget.value = null + selectedGalleryMediaUrls.value = [] +} + +/** + * 갤러리 미디어 선택 상태 전환 + * @param {Object} mediaItem - 미디어 항목 + * @returns {void} + */ +const toggleGalleryMediaSelection = (mediaItem) => { + if (selectedGalleryMediaUrls.value.includes(mediaItem.url)) { + selectedGalleryMediaUrls.value = selectedGalleryMediaUrls.value.filter((url) => url !== mediaItem.url) + return + } + + selectedGalleryMediaUrls.value = [...selectedGalleryMediaUrls.value, mediaItem.url] +} + +/** + * 갤러리 미디어 선택을 블록에 적용 + * @returns {void} + */ +const applyGalleryMediaSelection = () => { + const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId) + + if (!block || mediaPickerTarget.value?.type !== 'gallery') { + return + } + + const existingImages = new Map(block.images.map((image) => [image.url, image])) + block.images = selectedGalleryMediaUrls.value.map((url) => existingImages.get(url) || { + url, + alt: '', + width: 'regular' + }) + + normalizeTrailingTextBlock() + emitContent() + closeMediaPicker() } /** @@ -972,26 +1030,19 @@ const closeMediaPicker = () => { * @returns {void} */ const selectMediaItem = (mediaItem) => { + if (mediaPickerTarget.value?.type === 'gallery') { + toggleGalleryMediaSelection(mediaItem) + return + } + const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId) if (!block) { return } - if (mediaPickerTarget.value.type === 'gallery') { - block.images = [ - ...block.images, - { - url: mediaItem.url, - alt: '', - width: 'regular' - } - ] - } else { - block.url = mediaItem.url - block.alt = '' - } - + block.url = mediaItem.url + block.alt = '' normalizeTrailingTextBlock() emitContent() closeMediaPicker() @@ -1084,6 +1135,88 @@ const removeGalleryImage = (block, imageIndex) => { emitContent() } +/** + * 갤러리 이미지 드래그 시작 + * @param {DragEvent} event - 드래그 이벤트 + * @param {Object} block - 갤러리 블록 + * @param {number} imageIndex - 이미지 인덱스 + * @returns {void} + */ +const startGalleryImageDrag = (event, block, imageIndex) => { + draggingGalleryImage.value = { + blockId: block.id, + imageIndex + } + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', `${block.id}:${imageIndex}`) +} + +/** + * 갤러리 이미지 드래그 종료 + * @returns {void} + */ +const finishGalleryImageDrag = () => { + draggingGalleryImage.value = null + galleryDragTarget.value = null +} + +/** + * 갤러리 이미지 삽입 위치 표시 + * @param {DragEvent} event - 드래그 이벤트 + * @param {Object} block - 갤러리 블록 + * @param {number} imageIndex - 이미지 인덱스 + * @returns {void} + */ +const updateGalleryImageDropTarget = (event, block, imageIndex) => { + const source = draggingGalleryImage.value + + if (!source || source.blockId !== block.id) { + return + } + + const rect = event.currentTarget.getBoundingClientRect() + galleryDragTarget.value = { + blockId: block.id, + imageIndex, + position: event.clientX < rect.left + rect.width / 2 ? 'before' : 'after' + } +} + +/** + * 갤러리 이미지 순서 변경 + * @param {DragEvent} event - 드롭 이벤트 + * @param {Object} block - 갤러리 블록 + * @param {number} targetIndex - 대상 인덱스 + * @returns {void} + */ +const dropGalleryImage = (event, block, targetIndex) => { + const source = draggingGalleryImage.value + const target = galleryDragTarget.value + + if (!source || source.blockId !== block.id || source.imageIndex === targetIndex) { + return + } + + let nextTargetIndex = target?.blockId === block.id && target.position === 'after' + ? targetIndex + 1 + : targetIndex + + if (source.imageIndex < nextTargetIndex) { + nextTargetIndex -= 1 + } + + if (source.imageIndex === nextTargetIndex) { + finishGalleryImageDrag() + return + } + + const [image] = block.images.splice(source.imageIndex, 1) + block.images.splice(nextTargetIndex, 0, image) + finishGalleryImageDrag() + normalizeTrailingTextBlock() + emitContent() +} + /** * 슬래시 메뉴 선택을 아래로 이동 * @param {KeyboardEvent} event - 키보드 이벤트 @@ -1607,13 +1740,30 @@ defineExpose({ @keydown.enter="handleEnter($event, index)" @keydown.backspace="handleBackspace($event, index)" > - @@ -1882,4 +2056,30 @@ defineExpose({ color: #f8fafc; caret-color: #f8fafc; } + +.admin-block-editor__gallery-item--drop-before::before, +.admin-block-editor__gallery-item--drop-after::before { + position: absolute; + top: 8px; + bottom: 8px; + z-index: 20; + width: 4px; + border-radius: 999px; + background: #2eb6ea; + box-shadow: + 0 0 0 3px rgba(46, 182, 234, 0.16), + 0 6px 18px rgba(46, 182, 234, 0.35); + content: ""; + pointer-events: none; +} + +.admin-block-editor__gallery-item--drop-before::before { + left: 0; + transform: translateX(-50%); +} + +.admin-block-editor__gallery-item--drop-after::before { + right: 0; + transform: translateX(50%); +} diff --git a/docs/history.md b/docs/history.md index d0bf332..0b4c691 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-13 v0.0.117 + +### 갤러리 선택과 순서 편집 흐름 정리 + +갤러리는 단일 이미지 블록과 달리 여러 이미지를 한 번에 구성하는 블록이므로, 미디어 클릭 즉시 적용하면 선택을 이어갈 수 없고 실수 수정도 번거롭다. 갤러리 미디어 선택은 모달 안에서 복수 선택 상태를 유지한 뒤 확인 시점에 블록에 반영한다. 이미지 개수별 열 수를 1·2·3열로 제한해 빈 칸을 줄이고, 작성자가 시각 흐름을 직접 정할 수 있도록 갤러리 내부 이미지는 드래그로 재정렬한다. 드래그 중에는 이미지 사이 삽입 위치를 선으로 표시해 어느 위치에 들어갈지 명확히 보여준다. + ## 2026-05-13 v0.0.116 ### 게시글 제목 IME 입력과 목록 태그 표시 보정 diff --git a/docs/map.md b/docs/map.md index 8893114..a3e207b 100644 --- a/docs/map.md +++ b/docs/map.md @@ -58,7 +58,7 @@ |------|-----------| | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 미저장 변경사항 이탈 확인, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | -| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | +| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | | components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) | | components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) | diff --git a/docs/spec.md b/docs/spec.md index 3a982ea..63770c3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -510,6 +510,9 @@ components/content/ - 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다. - 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다. - 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다. +- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다. +- 관리자 갤러리 블록은 이미지 1개일 때 1열, 2개일 때 2열, 3개 이상일 때 3열로 표시한다. +- 관리자 갤러리 블록의 이미지 순서는 드래그 앤 드롭으로 변경하며, 드래그 중 삽입 위치를 이미지 사이 라인으로 표시한다. - 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다. - 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다. - 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다. diff --git a/docs/update.md b/docs/update.md index d025b7c..3db33d9 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v0.0.117 + +- 관리자 글쓰기 갤러리 미디어 선택을 복수 선택 후 확인 적용 방식으로 변경. +- 관리자 갤러리 블록의 이미지 수에 따라 1개는 전체 너비, 2개는 2열, 3개 이상은 3열로 표시하도록 수정. +- 관리자 갤러리 블록 이미지 드래그 순서 변경과 삽입 위치 표시 추가. +- 패키지 버전 `0.0.117`로 갱신. + ## v0.0.116 - 관리자 게시글 제목 입력에서 한글 조합 중 Enter가 본문으로 마지막 글자를 넘기지 않도록 IME 조합 상태 가드 추가. diff --git a/package-lock.json b/package-lock.json index fc015b0..83c2a2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "0.0.116", + "version": "0.0.117", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.116", + "version": "0.0.117", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 80dcc23..6ae2064 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.116", + "version": "0.0.117", "private": true, "type": "module", "imports": {