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