갤러리 선택과 드래그 순서 개선

This commit is contained in:
2026-05-13 16:12:51 +09:00
parent 020471a1b8
commit 965a8fd1f6
7 changed files with 236 additions and 20 deletions

View File

@@ -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)"
>
<div v-if="block.images.length" class="admin-block-editor__gallery-grid grid grid-cols-2 gap-2 md:grid-cols-3">
<div
v-if="block.images.length"
class="admin-block-editor__gallery-grid grid gap-2"
:style="{ gridTemplateColumns: `repeat(${getGalleryColumnCount(block)}, minmax(0, 1fr))` }"
>
<div
v-for="(image, imageIndex) in block.images"
:key="`${block.id}-${image.url}`"
class="admin-block-editor__gallery-item group/item relative overflow-hidden rounded bg-surface"
:class="{
'opacity-50': draggingGalleryImage?.blockId === block.id && draggingGalleryImage?.imageIndex === imageIndex,
'admin-block-editor__gallery-item--drop-before': galleryDragTarget?.blockId === block.id && galleryDragTarget?.imageIndex === imageIndex && galleryDragTarget?.position === 'before',
'admin-block-editor__gallery-item--drop-after': galleryDragTarget?.blockId === block.id && galleryDragTarget?.imageIndex === imageIndex && galleryDragTarget?.position === 'after'
}"
draggable="true"
@dragstart.stop="startGalleryImageDrag($event, block, imageIndex)"
@dragover.prevent.stop="updateGalleryImageDropTarget($event, block, imageIndex)"
@drop.prevent.stop="dropGalleryImage($event, block, imageIndex)"
@dragend="finishGalleryImageDrag"
>
<img class="admin-block-editor__gallery-image aspect-[4/3] w-full object-cover" :src="image.url" :alt="image.alt">
<span class="admin-block-editor__gallery-drag-hint pointer-events-none absolute left-2 top-2 rounded bg-black/70 px-2 py-1 text-xs font-semibold text-white opacity-0 transition-opacity group-hover/item:opacity-100">
드래그
</span>
<button
class="admin-block-editor__gallery-remove absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-ink opacity-0 shadow transition-opacity group-hover/item:opacity-100"
type="button"
@@ -1752,11 +1902,19 @@ defineExpose({
<button
v-for="item in mediaItems"
:key="item.url"
class="admin-block-editor__media-picker-item overflow-hidden border border-line bg-white text-left"
class="admin-block-editor__media-picker-item relative overflow-hidden border bg-white text-left transition-colors"
:class="mediaPickerTarget?.type === 'gallery' && isGalleryMediaSelected(item) ? 'border-[#15171a] ring-2 ring-[#15171a]/20' : 'border-line hover:border-[#8e9cac]'"
type="button"
@click="selectMediaItem(item)"
>
<img class="admin-block-editor__media-picker-image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<span
v-if="mediaPickerTarget?.type === 'gallery' && isGalleryMediaSelected(item)"
class="admin-block-editor__media-picker-selected absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[#15171a] text-xs font-bold text-white"
aria-hidden="true"
>
</span>
<span class="admin-block-editor__media-picker-name block truncate px-3 py-2 text-xs font-semibold text-ink">{{ item.name }}</span>
</button>
</div>
@@ -1764,6 +1922,22 @@ defineExpose({
선택할 미디어가 없습니다.
</p>
</div>
<div
v-if="mediaPickerTarget?.type === 'gallery'"
class="admin-block-editor__media-picker-footer flex items-center justify-between gap-3 border-t border-line px-5 py-4"
>
<p class="admin-block-editor__media-picker-count text-sm text-muted">
{{ selectedGalleryMediaUrls.length }} 선택됨
</p>
<div class="admin-block-editor__media-picker-actions flex gap-2">
<button class="admin-block-editor__media-picker-cancel rounded border border-line px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeMediaPicker">
취소
</button>
<button class="admin-block-editor__media-picker-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" type="button" @click="applyGalleryMediaSelection">
갤러리에 적용
</button>
</div>
</div>
</section>
</div>
</div>
@@ -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%);
}
</style>

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-13 v0.0.117
### 갤러리 선택과 순서 편집 흐름 정리
갤러리는 단일 이미지 블록과 달리 여러 이미지를 한 번에 구성하는 블록이므로, 미디어 클릭 즉시 적용하면 선택을 이어갈 수 없고 실수 수정도 번거롭다. 갤러리 미디어 선택은 모달 안에서 복수 선택 상태를 유지한 뒤 확인 시점에 블록에 반영한다. 이미지 개수별 열 수를 1·2·3열로 제한해 빈 칸을 줄이고, 작성자가 시각 흐름을 직접 정할 수 있도록 갤러리 내부 이미지는 드래그로 재정렬한다. 드래그 중에는 이미지 사이 삽입 위치를 선으로 표시해 어느 위치에 들어갈지 명확히 보여준다.
## 2026-05-13 v0.0.116
### 게시글 제목 IME 입력과 목록 태그 표시 보정

View File

@@ -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 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |

View File

@@ -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 안에 본문을 저장한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v0.0.117
- 관리자 글쓰기 갤러리 미디어 선택을 복수 선택 후 확인 적용 방식으로 변경.
- 관리자 갤러리 블록의 이미지 수에 따라 1개는 전체 너비, 2개는 2열, 3개 이상은 3열로 표시하도록 수정.
- 관리자 갤러리 블록 이미지 드래그 순서 변경과 삽입 위치 표시 추가.
- 패키지 버전 `0.0.117`로 갱신.
## v0.0.116
- 관리자 게시글 제목 입력에서 한글 조합 중 Enter가 본문으로 마지막 글자를 넘기지 않도록 IME 조합 상태 가드 추가.

4
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.116",
"version": "0.0.117",
"private": true,
"type": "module",
"imports": {