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)" > -