공개 본문 이미지 드래그 상태 분리
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
|||||||
import { getGalleryRows } from '../../lib/content-gallery-images.js'
|
import { getGalleryRows } from '../../lib/content-gallery-images.js'
|
||||||
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
|
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
|
||||||
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
|
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
|
||||||
|
import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js'
|
||||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||||
import ProseHtml from './ProseHtml.vue'
|
import ProseHtml from './ProseHtml.vue'
|
||||||
@@ -95,20 +96,10 @@ const emit = defineEmits([
|
|||||||
'content-replace'
|
'content-replace'
|
||||||
])
|
])
|
||||||
|
|
||||||
/** @type {string} 라이브 이미지·갤러리 드래그 payload MIME */
|
|
||||||
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
|
|
||||||
/** @type {string} 본문 리스트 마커 색상 */
|
/** @type {string} 본문 리스트 마커 색상 */
|
||||||
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
|
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
|
||||||
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
|
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
|
||||||
const galleryImageAspectRatios = ref({})
|
const galleryImageAspectRatios = ref({})
|
||||||
/** @type {import('vue').Ref<{ kind: 'image'|'gallery', sourceLine?: number, startLine?: number, endLine?: number, blockId: string, imageIndex?: number, image?: Object }|null>} */
|
|
||||||
const liveImageDragState = ref(null)
|
|
||||||
/** @type {import('vue').Ref<{ blockId: string, targetIndex: number }|null>} */
|
|
||||||
const galleryDropTarget = ref(null)
|
|
||||||
/** @type {import('vue').Ref<{ blockId: string }|null>} */
|
|
||||||
const imageBlockDropTarget = ref(null)
|
|
||||||
/** @type {import('vue').Ref<{ insertBeforeLine: number }|null>} */
|
|
||||||
const blockInsertDropTarget = ref(null)
|
|
||||||
/** @type {import('vue').Ref<number|null>} */
|
/** @type {import('vue').Ref<number|null>} */
|
||||||
const pendingFocusLine = ref(null)
|
const pendingFocusLine = ref(null)
|
||||||
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
|
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
|
||||||
@@ -143,6 +134,36 @@ const {
|
|||||||
inArticleAdCode: computed(() => props.inArticleAdCode)
|
inArticleAdCode: computed(() => props.inArticleAdCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
liveImageDragState,
|
||||||
|
galleryDropTarget,
|
||||||
|
imageBlockDropTarget,
|
||||||
|
blockInsertDropTarget,
|
||||||
|
getBlockInsertBeforeLine,
|
||||||
|
onImageBlockDragStart,
|
||||||
|
onImageBlockDragOver,
|
||||||
|
onImageBlockDragLeave,
|
||||||
|
onImageBlockDrop,
|
||||||
|
onImageBlockDragEnd,
|
||||||
|
onBlockInsertDragOver,
|
||||||
|
onBlockInsertDragLeave,
|
||||||
|
onBlockInsertDrop,
|
||||||
|
onTailInsertDragOver,
|
||||||
|
onTailInsertDrop,
|
||||||
|
onGalleryDragStart,
|
||||||
|
onGalleryDragEnd,
|
||||||
|
onGalleryDragOverItem,
|
||||||
|
onGalleryDragLeaveGallery,
|
||||||
|
onGalleryDrop
|
||||||
|
} = useContentMarkdownImageDrag({
|
||||||
|
interactive: computed(() => props.interactive),
|
||||||
|
tailInsertBeforeLine,
|
||||||
|
onMergeImagesToGallery: (payload) => emit('merge-images-to-gallery', payload),
|
||||||
|
onInsertImageToGallery: (payload) => emit('insert-image-to-gallery', payload),
|
||||||
|
onExtractGalleryImage: (payload) => emit('extract-gallery-image', payload),
|
||||||
|
onGalleryReorder: (payload) => emit('gallery-reorder', payload)
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 줄 공백 블록 높이를 반환한다.
|
* 빈 줄 공백 블록 높이를 반환한다.
|
||||||
* @param {Object} block - 렌더링 블록
|
* @param {Object} block - 렌더링 블록
|
||||||
@@ -1582,433 +1603,6 @@ const openContentLightbox = (lightboxKey) => {
|
|||||||
openContentLightboxFromImages(contentLightboxImages.value, lightboxKey)
|
openContentLightboxFromImages(contentLightboxImages.value, lightboxKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 블록 앞 삽입 위치(0-based 줄)를 반환한다.
|
|
||||||
* @param {Object} block - 콘텐츠 블록
|
|
||||||
* @returns {number|null} 삽입 줄 번호
|
|
||||||
*/
|
|
||||||
const getBlockInsertBeforeLine = (block) => {
|
|
||||||
const line = block?.meta?.startLine
|
|
||||||
|
|
||||||
return typeof line === 'number' ? line : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 블록을 드래그 payload용 객체로 변환한다.
|
|
||||||
* @param {Object} block - 이미지 블록
|
|
||||||
* @returns {Object} 이미지 데이터
|
|
||||||
*/
|
|
||||||
const toImageDragPayload = (block) => ({
|
|
||||||
url: block.url,
|
|
||||||
caption: block.caption,
|
|
||||||
width: block.width,
|
|
||||||
useAlt: block.useAlt,
|
|
||||||
legacyBracketLabel: block.legacyBracketLabel
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이브 이미지 드래그 payload를 dataTransfer에 기록한다.
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @param {Object} payload - 드래그 payload
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const writeLiveImageDragData = (event, payload) => {
|
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
|
||||||
event.dataTransfer.setData(LIVE_IMAGE_DRAG_MIME, JSON.stringify(payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 드래그 payload를 읽는다.
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @returns {Object|null} payload
|
|
||||||
*/
|
|
||||||
const readLiveImageDragData = (event) => {
|
|
||||||
try {
|
|
||||||
const raw = event.dataTransfer.getData(LIVE_IMAGE_DRAG_MIME)
|
|
||||||
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이브 이미지·갤러리 드래그 상태를 초기화한다.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const clearLiveImageDragUi = () => {
|
|
||||||
liveImageDragState.value = null
|
|
||||||
galleryDropTarget.value = null
|
|
||||||
imageBlockDropTarget.value = null
|
|
||||||
blockInsertDropTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이브 단일 이미지 블록 드래그 시작
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @param {Object} block - 이미지 블록
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onImageBlockDragStart = (event, block) => {
|
|
||||||
if (!props.interactive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.target instanceof Element && event.target.closest('button')) {
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
kind: 'image',
|
|
||||||
sourceLine: block.meta.startLine,
|
|
||||||
blockId: block.id,
|
|
||||||
image: toImageDragPayload(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
liveImageDragState.value = payload
|
|
||||||
writeLiveImageDragData(event, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이브 이미지 블록 위 드래그 오버(갤러리 병합 대상)
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @param {Object} block - 대상 이미지 블록
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onImageBlockDragOver = (event, block) => {
|
|
||||||
if (!props.interactive || liveImageDragState.value?.kind !== 'image') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveImageDragState.value.sourceLine === block.meta.startLine) {
|
|
||||||
imageBlockDropTarget.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.dataTransfer.dropEffect = 'move'
|
|
||||||
imageBlockDropTarget.value = { blockId: block.id }
|
|
||||||
blockInsertDropTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이브 이미지 블록 드래그 이탈
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @param {Object} block - 이미지 블록
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onImageBlockDragLeave = (event, block) => {
|
|
||||||
const related = event.relatedTarget
|
|
||||||
|
|
||||||
if (related && event.currentTarget.contains(related)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imageBlockDropTarget.value?.blockId === block.id) {
|
|
||||||
imageBlockDropTarget.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이브 이미지 두 블록 병합 드롭
|
|
||||||
* @param {DragEvent} event - 드롭 이벤트
|
|
||||||
* @param {Object} block - 대상 이미지 블록
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onImageBlockDrop = (event, block) => {
|
|
||||||
if (!props.interactive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
|
||||||
|
|
||||||
if (payload?.kind !== 'image' || typeof payload.sourceLine !== 'number') {
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.sourceLine === block.meta.startLine) {
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('merge-images-to-gallery', {
|
|
||||||
sourceLine: payload.sourceLine,
|
|
||||||
targetLine: block.meta.startLine
|
|
||||||
})
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이브 이미지 블록 드래그 종료
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onImageBlockDragEnd = () => {
|
|
||||||
imageBlockDropTarget.value = null
|
|
||||||
|
|
||||||
if (liveImageDragState.value?.kind === 'image') {
|
|
||||||
liveImageDragState.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 블록 사이 삽입선 드래그 오버(갤러리 이미지 분리)
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @param {Object} block - 기준 블록
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onBlockInsertDragOver = (event, block) => {
|
|
||||||
const insertBeforeLine = getBlockInsertBeforeLine(block)
|
|
||||||
|
|
||||||
if (!props.interactive || insertBeforeLine === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragKind = liveImageDragState.value?.kind || readLiveImageDragData(event)?.kind
|
|
||||||
|
|
||||||
if (dragKind !== 'gallery') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.dataTransfer.dropEffect = 'move'
|
|
||||||
blockInsertDropTarget.value = { insertBeforeLine }
|
|
||||||
galleryDropTarget.value = null
|
|
||||||
imageBlockDropTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 블록 사이 삽입선 드래그 이탈
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onBlockInsertDragLeave = (event) => {
|
|
||||||
const related = event.relatedTarget
|
|
||||||
|
|
||||||
if (related && event.currentTarget.contains(related)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
blockInsertDropTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 블록 사이에 갤러리 이미지를 단일 블록으로 분리해 삽입
|
|
||||||
* @param {DragEvent} event - 드롭 이벤트
|
|
||||||
* @param {Object} block - 기준 블록
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onBlockInsertDrop = (event, block) => {
|
|
||||||
const insertBeforeLine = getBlockInsertBeforeLine(block)
|
|
||||||
|
|
||||||
if (!props.interactive || insertBeforeLine === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
|
||||||
|
|
||||||
if (payload?.kind !== 'gallery'
|
|
||||||
|| typeof payload.startLine !== 'number'
|
|
||||||
|| typeof payload.endLine !== 'number'
|
|
||||||
|| typeof payload.imageIndex !== 'number') {
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('extract-gallery-image', {
|
|
||||||
startLine: payload.startLine,
|
|
||||||
endLine: payload.endLine,
|
|
||||||
imageIndex: payload.imageIndex,
|
|
||||||
insertBeforeLine,
|
|
||||||
image: payload.image
|
|
||||||
})
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 문서 맨 아래 삽입선 드래그 오버
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onTailInsertDragOver = (event) => {
|
|
||||||
if (!props.interactive || liveImageDragState.value?.kind !== 'gallery') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.dataTransfer.dropEffect = 'move'
|
|
||||||
blockInsertDropTarget.value = { insertBeforeLine: tailInsertBeforeLine.value }
|
|
||||||
galleryDropTarget.value = null
|
|
||||||
imageBlockDropTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 문서 맨 아래에 갤러리 이미지 분리 삽입
|
|
||||||
* @param {DragEvent} event - 드롭 이벤트
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onTailInsertDrop = (event) => {
|
|
||||||
if (!props.interactive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
|
||||||
const insertBeforeLine = tailInsertBeforeLine.value
|
|
||||||
|
|
||||||
if (payload?.kind !== 'gallery'
|
|
||||||
|| typeof payload.startLine !== 'number'
|
|
||||||
|| typeof payload.endLine !== 'number'
|
|
||||||
|| typeof payload.imageIndex !== 'number') {
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('extract-gallery-image', {
|
|
||||||
startLine: payload.startLine,
|
|
||||||
endLine: payload.endLine,
|
|
||||||
imageIndex: payload.imageIndex,
|
|
||||||
insertBeforeLine,
|
|
||||||
image: payload.image
|
|
||||||
})
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 갤러리 드래그 시작
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @param {Object} block - 갤러리 블록
|
|
||||||
* @param {number} imageIndex - 이미지 인덱스
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onGalleryDragStart = (event, block, imageIndex) => {
|
|
||||||
if (!props.interactive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = block.images[imageIndex]
|
|
||||||
const payload = {
|
|
||||||
kind: 'gallery',
|
|
||||||
startLine: block.meta.startLine,
|
|
||||||
endLine: block.meta.endLine,
|
|
||||||
blockId: block.id,
|
|
||||||
imageIndex,
|
|
||||||
image
|
|
||||||
}
|
|
||||||
|
|
||||||
liveImageDragState.value = payload
|
|
||||||
writeLiveImageDragData(event, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 갤러리 드래그 종료
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onGalleryDragEnd = () => {
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 갤러리 드래그 중 드롭 대상 셀 표시
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @param {Object} block - 갤러리 블록
|
|
||||||
* @param {number} imageIndex - 호버 중인 이미지 인덱스
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onGalleryDragOverItem = (event, block, imageIndex) => {
|
|
||||||
if (!props.interactive || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
blockInsertDropTarget.value = null
|
|
||||||
|
|
||||||
if (liveImageDragState.value.kind === 'image') {
|
|
||||||
event.dataTransfer.dropEffect = 'move'
|
|
||||||
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
|
|
||||||
imageBlockDropTarget.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveImageDragState.value.blockId !== block.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveImageDragState.value.imageIndex === imageIndex) {
|
|
||||||
galleryDropTarget.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 갤러리 영역을 벗어나면 드롭 대상 표시를 해제한다.
|
|
||||||
* @param {DragEvent} event - 드래그 이벤트
|
|
||||||
* @param {Object} block - 갤러리 블록
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onGalleryDragLeaveGallery = (event, block) => {
|
|
||||||
const related = event.relatedTarget
|
|
||||||
|
|
||||||
if (related && event.currentTarget.contains(related)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (galleryDropTarget.value?.blockId === block.id) {
|
|
||||||
galleryDropTarget.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 갤러리 드롭으로 순서 변경
|
|
||||||
* @param {DragEvent} event - 드롭 이벤트
|
|
||||||
* @param {Object} block - 갤러리 블록
|
|
||||||
* @param {number} targetIndex - 대상 인덱스
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onGalleryDrop = (event, block, targetIndex) => {
|
|
||||||
if (!props.interactive || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
if (liveImageDragState.value.kind === 'image') {
|
|
||||||
emit('insert-image-to-gallery', {
|
|
||||||
sourceLine: liveImageDragState.value.sourceLine,
|
|
||||||
startLine: block.meta?.startLine,
|
|
||||||
endLine: block.meta?.endLine,
|
|
||||||
targetIndex: targetIndex + 1,
|
|
||||||
image: liveImageDragState.value.image
|
|
||||||
})
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { blockId, imageIndex: fromIndex } = liveImageDragState.value
|
|
||||||
|
|
||||||
if (blockId !== block.id || fromIndex === targetIndex) {
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const images = [...block.images]
|
|
||||||
const [moved] = images.splice(fromIndex, 1)
|
|
||||||
images.splice(targetIndex, 0, moved)
|
|
||||||
|
|
||||||
emit('gallery-reorder', {
|
|
||||||
startLine: block.meta?.startLine,
|
|
||||||
endLine: block.meta?.endLine,
|
|
||||||
images
|
|
||||||
})
|
|
||||||
|
|
||||||
clearLiveImageDragUi()
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
494
composables/useContentMarkdownImageDrag.js
Normal file
494
composables/useContentMarkdownImageDrag.js
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { ref, unref } from 'vue'
|
||||||
|
|
||||||
|
/** @type {string} 라이브 이미지·갤러리 드래그 payload MIME */
|
||||||
|
export const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록 앞 삽입 위치(0-based 줄)를 반환한다.
|
||||||
|
* @param {Object} block - 콘텐츠 블록
|
||||||
|
* @returns {number|null} 삽입 줄 번호
|
||||||
|
*/
|
||||||
|
export const getContentBlockInsertBeforeLine = (block) => {
|
||||||
|
const line = block?.meta?.startLine
|
||||||
|
|
||||||
|
return typeof line === 'number' ? line : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 블록을 드래그 payload용 객체로 변환한다.
|
||||||
|
* @param {Object} block - 이미지 블록
|
||||||
|
* @returns {Object} 이미지 데이터
|
||||||
|
*/
|
||||||
|
export const toContentImageDragPayload = (block) => ({
|
||||||
|
url: block.url,
|
||||||
|
caption: block.caption,
|
||||||
|
width: block.width,
|
||||||
|
useAlt: block.useAlt,
|
||||||
|
legacyBracketLabel: block.legacyBracketLabel
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 Markdown 라이브 이미지·갤러리 드래그 상태를 만든다.
|
||||||
|
* @param {Object} options - 드래그 옵션
|
||||||
|
* @param {import('vue').Ref<boolean>|boolean} options.interactive - 라이브 편집 가능 여부
|
||||||
|
* @param {import('vue').Ref<number>|number} options.tailInsertBeforeLine - 문서 하단 삽입 줄
|
||||||
|
* @param {(payload: Object) => void} options.onMergeImagesToGallery - 단일 이미지 병합 처리
|
||||||
|
* @param {(payload: Object) => void} options.onInsertImageToGallery - 단일 이미지 갤러리 삽입 처리
|
||||||
|
* @param {(payload: Object) => void} options.onExtractGalleryImage - 갤러리 이미지 분리 처리
|
||||||
|
* @param {(payload: Object) => void} options.onGalleryReorder - 갤러리 순서 변경 처리
|
||||||
|
* @returns {Object} 드래그 상태와 이벤트 핸들러
|
||||||
|
*/
|
||||||
|
export const useContentMarkdownImageDrag = (options) => {
|
||||||
|
/** @type {import('vue').Ref<{ kind: 'image'|'gallery', sourceLine?: number, startLine?: number, endLine?: number, blockId: string, imageIndex?: number, image?: Object }|null>} */
|
||||||
|
const liveImageDragState = ref(null)
|
||||||
|
/** @type {import('vue').Ref<{ blockId: string, targetIndex: number }|null>} */
|
||||||
|
const galleryDropTarget = ref(null)
|
||||||
|
/** @type {import('vue').Ref<{ blockId: string }|null>} */
|
||||||
|
const imageBlockDropTarget = ref(null)
|
||||||
|
/** @type {import('vue').Ref<{ insertBeforeLine: number }|null>} */
|
||||||
|
const blockInsertDropTarget = ref(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이브 편집 가능 여부를 반환한다.
|
||||||
|
* @returns {boolean} 편집 가능 여부
|
||||||
|
*/
|
||||||
|
const isInteractive = () => Boolean(unref(options.interactive))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이브 이미지 드래그 payload를 dataTransfer에 기록한다.
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @param {Object} payload - 드래그 payload
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const writeLiveImageDragData = (event, payload) => {
|
||||||
|
if (!event?.dataTransfer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData(LIVE_IMAGE_DRAG_MIME, JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 payload를 읽는다.
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @returns {Object|null} payload
|
||||||
|
*/
|
||||||
|
const readLiveImageDragData = (event) => {
|
||||||
|
try {
|
||||||
|
const raw = event?.dataTransfer?.getData(LIVE_IMAGE_DRAG_MIME)
|
||||||
|
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이브 이미지·갤러리 드래그 상태를 초기화한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const clearLiveImageDragUi = () => {
|
||||||
|
liveImageDragState.value = null
|
||||||
|
galleryDropTarget.value = null
|
||||||
|
imageBlockDropTarget.value = null
|
||||||
|
blockInsertDropTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 요소 내부 이동인지 확인한다.
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @returns {boolean} 내부 이동 여부
|
||||||
|
*/
|
||||||
|
const isLeavingInsideCurrentTarget = (event) => {
|
||||||
|
const related = event?.relatedTarget
|
||||||
|
|
||||||
|
return Boolean(related && event?.currentTarget?.contains?.(related))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이브 단일 이미지 블록 드래그 시작
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 이미지 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onImageBlockDragStart = (event, block) => {
|
||||||
|
if (!isInteractive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof Element !== 'undefined' && event.target instanceof Element && event.target.closest('button')) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
kind: 'image',
|
||||||
|
sourceLine: block.meta.startLine,
|
||||||
|
blockId: block.id,
|
||||||
|
image: toContentImageDragPayload(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
liveImageDragState.value = payload
|
||||||
|
writeLiveImageDragData(event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이브 이미지 블록 위 드래그 오버(갤러리 병합 대상)
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 대상 이미지 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onImageBlockDragOver = (event, block) => {
|
||||||
|
if (!isInteractive() || liveImageDragState.value?.kind !== 'image') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveImageDragState.value.sourceLine === block.meta.startLine) {
|
||||||
|
imageBlockDropTarget.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
imageBlockDropTarget.value = { blockId: block.id }
|
||||||
|
blockInsertDropTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이브 이미지 블록 드래그 이탈
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 이미지 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onImageBlockDragLeave = (event, block) => {
|
||||||
|
if (isLeavingInsideCurrentTarget(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageBlockDropTarget.value?.blockId === block.id) {
|
||||||
|
imageBlockDropTarget.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이브 이미지 두 블록 병합 드롭
|
||||||
|
* @param {DragEvent|Object} event - 드롭 이벤트
|
||||||
|
* @param {Object} block - 대상 이미지 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onImageBlockDrop = (event, block) => {
|
||||||
|
if (!isInteractive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
||||||
|
|
||||||
|
if (payload?.kind !== 'image' || typeof payload.sourceLine !== 'number') {
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.sourceLine === block.meta.startLine) {
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onMergeImagesToGallery({
|
||||||
|
sourceLine: payload.sourceLine,
|
||||||
|
targetLine: block.meta.startLine
|
||||||
|
})
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이브 이미지 블록 드래그 종료
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onImageBlockDragEnd = () => {
|
||||||
|
imageBlockDropTarget.value = null
|
||||||
|
|
||||||
|
if (liveImageDragState.value?.kind === 'image') {
|
||||||
|
liveImageDragState.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록 사이 삽입선 드래그 오버(갤러리 이미지 분리)
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 기준 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBlockInsertDragOver = (event, block) => {
|
||||||
|
const insertBeforeLine = getContentBlockInsertBeforeLine(block)
|
||||||
|
|
||||||
|
if (!isInteractive() || insertBeforeLine === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragKind = liveImageDragState.value?.kind || readLiveImageDragData(event)?.kind
|
||||||
|
|
||||||
|
if (dragKind !== 'gallery') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
blockInsertDropTarget.value = { insertBeforeLine }
|
||||||
|
galleryDropTarget.value = null
|
||||||
|
imageBlockDropTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록 사이 삽입선 드래그 이탈
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBlockInsertDragLeave = (event) => {
|
||||||
|
if (isLeavingInsideCurrentTarget(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
blockInsertDropTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록 사이에 갤러리 이미지를 단일 블록으로 분리해 삽입
|
||||||
|
* @param {DragEvent|Object} event - 드롭 이벤트
|
||||||
|
* @param {Object} block - 기준 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBlockInsertDrop = (event, block) => {
|
||||||
|
const insertBeforeLine = getContentBlockInsertBeforeLine(block)
|
||||||
|
|
||||||
|
if (!isInteractive() || insertBeforeLine === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
||||||
|
|
||||||
|
if (payload?.kind !== 'gallery'
|
||||||
|
|| typeof payload.startLine !== 'number'
|
||||||
|
|| typeof payload.endLine !== 'number'
|
||||||
|
|| typeof payload.imageIndex !== 'number') {
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onExtractGalleryImage({
|
||||||
|
startLine: payload.startLine,
|
||||||
|
endLine: payload.endLine,
|
||||||
|
imageIndex: payload.imageIndex,
|
||||||
|
insertBeforeLine,
|
||||||
|
image: payload.image
|
||||||
|
})
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 맨 아래 삽입선 드래그 오버
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onTailInsertDragOver = (event) => {
|
||||||
|
if (!isInteractive() || liveImageDragState.value?.kind !== 'gallery') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
blockInsertDropTarget.value = { insertBeforeLine: unref(options.tailInsertBeforeLine) }
|
||||||
|
galleryDropTarget.value = null
|
||||||
|
imageBlockDropTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 맨 아래에 갤러리 이미지 분리 삽입
|
||||||
|
* @param {DragEvent|Object} event - 드롭 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onTailInsertDrop = (event) => {
|
||||||
|
if (!isInteractive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
||||||
|
const insertBeforeLine = unref(options.tailInsertBeforeLine)
|
||||||
|
|
||||||
|
if (payload?.kind !== 'gallery'
|
||||||
|
|| typeof payload.startLine !== 'number'
|
||||||
|
|| typeof payload.endLine !== 'number'
|
||||||
|
|| typeof payload.imageIndex !== 'number') {
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onExtractGalleryImage({
|
||||||
|
startLine: payload.startLine,
|
||||||
|
endLine: payload.endLine,
|
||||||
|
imageIndex: payload.imageIndex,
|
||||||
|
insertBeforeLine,
|
||||||
|
image: payload.image
|
||||||
|
})
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 드래그 시작
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 갤러리 블록
|
||||||
|
* @param {number} imageIndex - 이미지 인덱스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onGalleryDragStart = (event, block, imageIndex) => {
|
||||||
|
if (!isInteractive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = block.images[imageIndex]
|
||||||
|
const payload = {
|
||||||
|
kind: 'gallery',
|
||||||
|
startLine: block.meta.startLine,
|
||||||
|
endLine: block.meta.endLine,
|
||||||
|
blockId: block.id,
|
||||||
|
imageIndex,
|
||||||
|
image
|
||||||
|
}
|
||||||
|
|
||||||
|
liveImageDragState.value = payload
|
||||||
|
writeLiveImageDragData(event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 드래그 종료
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onGalleryDragEnd = () => {
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 드래그 중 드롭 대상 셀 표시
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 갤러리 블록
|
||||||
|
* @param {number} imageIndex - 호버 중인 이미지 인덱스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onGalleryDragOverItem = (event, block, imageIndex) => {
|
||||||
|
if (!isInteractive() || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
blockInsertDropTarget.value = null
|
||||||
|
|
||||||
|
if (liveImageDragState.value.kind === 'image') {
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
|
||||||
|
imageBlockDropTarget.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveImageDragState.value.blockId !== block.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveImageDragState.value.imageIndex === imageIndex) {
|
||||||
|
galleryDropTarget.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 영역을 벗어나면 드롭 대상 표시를 해제한다.
|
||||||
|
* @param {DragEvent|Object} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 갤러리 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onGalleryDragLeaveGallery = (event, block) => {
|
||||||
|
if (isLeavingInsideCurrentTarget(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (galleryDropTarget.value?.blockId === block.id) {
|
||||||
|
galleryDropTarget.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 드롭으로 순서 변경
|
||||||
|
* @param {DragEvent|Object} event - 드롭 이벤트
|
||||||
|
* @param {Object} block - 갤러리 블록
|
||||||
|
* @param {number} targetIndex - 대상 인덱스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onGalleryDrop = (event, block, targetIndex) => {
|
||||||
|
if (!isInteractive() || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (liveImageDragState.value.kind === 'image') {
|
||||||
|
options.onInsertImageToGallery({
|
||||||
|
sourceLine: liveImageDragState.value.sourceLine,
|
||||||
|
startLine: block.meta?.startLine,
|
||||||
|
endLine: block.meta?.endLine,
|
||||||
|
targetIndex: targetIndex + 1,
|
||||||
|
image: liveImageDragState.value.image
|
||||||
|
})
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { blockId, imageIndex: fromIndex } = liveImageDragState.value
|
||||||
|
|
||||||
|
if (blockId !== block.id || fromIndex === targetIndex) {
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderedImages = [...block.images]
|
||||||
|
const [moved] = reorderedImages.splice(fromIndex, 1)
|
||||||
|
reorderedImages.splice(targetIndex, 0, moved)
|
||||||
|
|
||||||
|
options.onGalleryReorder({
|
||||||
|
startLine: block.meta.startLine,
|
||||||
|
endLine: block.meta.endLine,
|
||||||
|
images: reorderedImages
|
||||||
|
})
|
||||||
|
clearLiveImageDragUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
liveImageDragState,
|
||||||
|
galleryDropTarget,
|
||||||
|
imageBlockDropTarget,
|
||||||
|
blockInsertDropTarget,
|
||||||
|
getBlockInsertBeforeLine: getContentBlockInsertBeforeLine,
|
||||||
|
toImageDragPayload: toContentImageDragPayload,
|
||||||
|
readLiveImageDragData,
|
||||||
|
writeLiveImageDragData,
|
||||||
|
clearLiveImageDragUi,
|
||||||
|
onImageBlockDragStart,
|
||||||
|
onImageBlockDragOver,
|
||||||
|
onImageBlockDragLeave,
|
||||||
|
onImageBlockDrop,
|
||||||
|
onImageBlockDragEnd,
|
||||||
|
onBlockInsertDragOver,
|
||||||
|
onBlockInsertDragLeave,
|
||||||
|
onBlockInsertDrop,
|
||||||
|
onTailInsertDragOver,
|
||||||
|
onTailInsertDrop,
|
||||||
|
onGalleryDragStart,
|
||||||
|
onGalleryDragEnd,
|
||||||
|
onGalleryDragOverItem,
|
||||||
|
onGalleryDragLeaveGallery,
|
||||||
|
onGalleryDrop
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
## 2026-07-27 v1.11.97 — 공개 Markdown 이미지 드래그 상태를 렌더러 밖으로 옮긴다
|
||||||
|
|
||||||
|
단일 이미지와 갤러리의 라이브 드래그는 드래그 payload, drop target 표시, 병합·삽입·분리·정렬 이벤트를 함께 다루지만, 실제 본문 렌더링 UI와는 별도 상태다. `ContentMarkdownRenderer` 안에 계속 두면 이미지 UI를 수정할 때 렌더링·편집·드래그 책임이 한 파일에서 얽히므로, `useContentMarkdownImageDrag`로 분리해 렌더러는 템플릿 연결과 emit 위임만 담당하게 한다.
|
||||||
|
|
||||||
## 2026-07-27 v1.11.96 — 공개 Markdown 블록 계산을 렌더러 밖으로 옮긴다
|
## 2026-07-27 v1.11.96 — 공개 Markdown 블록 계산을 렌더러 밖으로 옮긴다
|
||||||
|
|
||||||
`ContentMarkdownRenderer`는 공개 본문 표시와 관리자 라이브 편집을 모두 연결하므로, 파싱·광고 삽입·제목 ID·라이트박스 대상 같은 계산까지 같은 파일에 있으면 실제 UI 책임과 데이터 준비 책임이 섞인다. 본문 문자열에서 렌더링 블록을 준비하는 계층을 `useContentMarkdownBlocks`로 분리해 렌더러는 준비된 블록을 화면에 배치하고 라이브 편집 이벤트를 중개하는 역할에 더 집중하도록 한다.
|
`ContentMarkdownRenderer`는 공개 본문 표시와 관리자 라이브 편집을 모두 연결하므로, 파싱·광고 삽입·제목 ID·라이트박스 대상 같은 계산까지 같은 파일에 있으면 실제 UI 책임과 데이터 준비 책임이 섞인다. 본문 문자열에서 렌더링 블록을 준비하는 계층을 `useContentMarkdownBlocks`로 분리해 렌더러는 준비된 블록을 화면에 배치하고 라이브 편집 이벤트를 중개하는 역할에 더 집중하도록 한다.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·억제 줄·키보드 탐색 타이머 관리 |
|
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·억제 줄·키보드 탐색 타이머 관리 |
|
||||||
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
|
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
|
||||||
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
||||||
|
| composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 |
|
||||||
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
|
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
|
||||||
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
|
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@
|
|||||||
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태 관리 단위 테스트 |
|
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태 관리 단위 테스트 |
|
||||||
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
|
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
|
||||||
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
|
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
|
||||||
|
| tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 |
|
||||||
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
||||||
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
|
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
|
||||||
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |
|
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |
|
||||||
@@ -264,7 +266,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 인라인 색상·굵게·링크 렌더링, 라이브 인용·콜아웃·토글 기본 목록 렌더링과 멀티라인 편집 줄 범위 포커스, 인용 `>. ` 접두 커밋 정리, 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 토글 저장 기본 펼침·닫힘 상태 반영, 공개 콜아웃·토글 내부 본문 블록 렌더링, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지 `{size=숫자}` 최대 너비 표시, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
|
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 인라인 색상·굵게·링크 렌더링, 라이브 인용·콜아웃·토글 기본 목록 렌더링과 멀티라인 편집 줄 범위 포커스, 인용 `>. ` 접두 커밋 정리, 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 토글 저장 기본 펼침·닫힘 상태 반영, 공개 콜아웃·토글 내부 본문 블록 렌더링, 라이브 이미지·갤러리 드래그 상태 연결, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지 `{size=숫자}` 최대 너비 표시, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
|
||||||
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 멀티라인 인라인 마크다운 렌더링, `blockMarkdown` 옵션 기반 기본 불릿/번호 목록 표시와 공개 목록 마커 색상 적용, plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
|
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 멀티라인 인라인 마크다운 렌더링, `blockMarkdown` 옵션 기반 기본 불릿/번호 목록 표시와 공개 목록 마커 색상 적용, plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
|
||||||
| components/content/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·굵게·기울임·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 |
|
| components/content/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·굵게·기울임·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 |
|
||||||
| components/content/ContentMarkdownBlockGroup.vue | Markdown 본문 블록 단위 렌더 그룹, 라이브 삽입 영역을 조건부로만 DOM에 추가해 공개 화면 fragment 주석 감소 |
|
| components/content/ContentMarkdownBlockGroup.vue | Markdown 본문 블록 단위 렌더 그룹, 라이브 삽입 영역을 조건부로만 DOM에 추가해 공개 화면 fragment 주석 감소 |
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
|
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
|
||||||
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
|
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
|
||||||
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다.
|
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다.
|
||||||
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
||||||
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
||||||
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
||||||
@@ -306,7 +306,7 @@ components/content/
|
|||||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 `lib/content-gallery-images.js` 기준 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다. 라이트박스 상태와 키보드 이벤트 연결은 `useContentMarkdownLightbox`에서 관리한다.
|
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 `lib/content-gallery-images.js` 기준 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다. 라이트박스 상태와 키보드 이벤트 연결은 `useContentMarkdownLightbox`에서 관리한다.
|
||||||
- 이미지 갤러리
|
- 이미지 갤러리
|
||||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||||
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음). 행 분할은 `lib/content-gallery-images.js`에서 수행하고, 라이트박스 목록 생성은 `useContentMarkdownBlocks`, 라이트박스 상태 관리는 `useContentMarkdownLightbox`에서 수행한다.
|
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음). 행 분할은 `lib/content-gallery-images.js`에서 수행하고, 라이트박스 목록 생성은 `useContentMarkdownBlocks`, 라이트박스 상태 관리는 `useContentMarkdownLightbox`, 라이브 모드 단일 이미지·갤러리 드래그 이벤트 중개는 `useContentMarkdownImageDrag`에서 수행한다.
|
||||||
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
|
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
|
||||||
- 비디오·오디오·파일 카드
|
- 비디오·오디오·파일 카드
|
||||||
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)
|
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 거대 컴포넌트 책임 분리 진행.
|
- 거대 컴포넌트 책임 분리 진행.
|
||||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||||
- `components/content/ContentMarkdownRenderer.vue`: 블록 렌더링 UI와 라이브 편집 중개 책임을 분리.
|
- `components/content/ContentMarkdownRenderer.vue`: 블록별 렌더링 UI와 텍스트 라이브 편집 중개 책임을 추가 분리.
|
||||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||||
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.97
|
||||||
|
|
||||||
|
- 공개 Markdown 렌더러의 라이브 단일 이미지·갤러리 드래그 상태와 이벤트 중개를 `useContentMarkdownImageDrag` composable로 분리.
|
||||||
|
- 단일 이미지 병합, 갤러리 삽입, 갤러리 순서 변경, 갤러리 이미지 분리 삽입 단위 테스트 추가.
|
||||||
|
|
||||||
## v1.11.96
|
## v1.11.96
|
||||||
|
|
||||||
- 공개 Markdown 렌더러의 본문 파싱·제목 ID·인아티클 광고 삽입 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산을 `useContentMarkdownBlocks` composable로 분리.
|
- 공개 Markdown 렌더러의 본문 파싱·제목 ID·인아티클 광고 삽입 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산을 `useContentMarkdownBlocks` composable로 분리.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.96",
|
"version": "1.11.97",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.96",
|
"version": "1.11.97",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.96",
|
"version": "1.11.97",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
218
tests/useContentMarkdownImageDrag.test.js
Normal file
218
tests/useContentMarkdownImageDrag.test.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
LIVE_IMAGE_DRAG_MIME,
|
||||||
|
getContentBlockInsertBeforeLine,
|
||||||
|
toContentImageDragPayload,
|
||||||
|
useContentMarkdownImageDrag
|
||||||
|
} from '../composables/useContentMarkdownImageDrag.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트용 드래그 이벤트를 만든다.
|
||||||
|
* @returns {Object} 드래그 이벤트 대역 객체
|
||||||
|
*/
|
||||||
|
const createDragEvent = () => {
|
||||||
|
const data = new Map()
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataTransfer: {
|
||||||
|
dropEffect: '',
|
||||||
|
effectAllowed: '',
|
||||||
|
setData: (type, value) => data.set(type, value),
|
||||||
|
getData: (type) => data.get(type) || ''
|
||||||
|
},
|
||||||
|
target: null,
|
||||||
|
relatedTarget: null,
|
||||||
|
currentTarget: {
|
||||||
|
contains: () => false
|
||||||
|
},
|
||||||
|
defaultPrevented: false,
|
||||||
|
preventDefault() {
|
||||||
|
this.defaultPrevented = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트용 이미지 블록을 만든다.
|
||||||
|
* @param {Object} options - 블록 옵션
|
||||||
|
* @returns {Object} 이미지 블록
|
||||||
|
*/
|
||||||
|
const createImageBlock = (options) => ({
|
||||||
|
id: options.id,
|
||||||
|
type: 'image',
|
||||||
|
url: options.url,
|
||||||
|
caption: options.caption || '',
|
||||||
|
width: options.width,
|
||||||
|
useAlt: Boolean(options.useAlt),
|
||||||
|
legacyBracketLabel: Boolean(options.legacyBracketLabel),
|
||||||
|
meta: {
|
||||||
|
startLine: options.startLine
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트용 갤러리 블록을 만든다.
|
||||||
|
* @returns {Object} 갤러리 블록
|
||||||
|
*/
|
||||||
|
const createGalleryBlock = () => ({
|
||||||
|
id: 'gallery-a',
|
||||||
|
type: 'gallery',
|
||||||
|
images: [
|
||||||
|
{ url: '/a.jpg', caption: 'A' },
|
||||||
|
{ url: '/b.jpg', caption: 'B' },
|
||||||
|
{ url: '/c.jpg', caption: 'C' }
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
startLine: 10,
|
||||||
|
endLine: 14
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트용 드래그 상태를 만든다.
|
||||||
|
* @param {Object} calls - 이벤트 기록 객체
|
||||||
|
* @returns {Object} 이미지 드래그 상태
|
||||||
|
*/
|
||||||
|
const createImageDragState = (calls = {}) => useContentMarkdownImageDrag({
|
||||||
|
interactive: ref(true),
|
||||||
|
tailInsertBeforeLine: ref(99),
|
||||||
|
onMergeImagesToGallery: (payload) => {
|
||||||
|
calls.merge = payload
|
||||||
|
},
|
||||||
|
onInsertImageToGallery: (payload) => {
|
||||||
|
calls.insert = payload
|
||||||
|
},
|
||||||
|
onExtractGalleryImage: (payload) => {
|
||||||
|
calls.extract = payload
|
||||||
|
},
|
||||||
|
onGalleryReorder: (payload) => {
|
||||||
|
calls.reorder = payload
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('라이브 이미지 드래그 payload는 단일 이미지 블록 데이터를 보존한다', () => {
|
||||||
|
const block = createImageBlock({
|
||||||
|
id: 'image-a',
|
||||||
|
url: '/uploads/a.jpg',
|
||||||
|
caption: '캡션',
|
||||||
|
width: 360,
|
||||||
|
useAlt: true,
|
||||||
|
legacyBracketLabel: true,
|
||||||
|
startLine: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(toContentImageDragPayload(block), {
|
||||||
|
url: '/uploads/a.jpg',
|
||||||
|
caption: '캡션',
|
||||||
|
width: 360,
|
||||||
|
useAlt: true,
|
||||||
|
legacyBracketLabel: true
|
||||||
|
})
|
||||||
|
assert.equal(getContentBlockInsertBeforeLine(block), 3)
|
||||||
|
assert.equal(getContentBlockInsertBeforeLine({}), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('단일 이미지를 다른 단일 이미지에 드롭하면 갤러리 병합 이벤트를 보낸다', () => {
|
||||||
|
const calls = {}
|
||||||
|
const drag = createImageDragState(calls)
|
||||||
|
const source = createImageBlock({ id: 'image-a', url: '/a.jpg', startLine: 2 })
|
||||||
|
const target = createImageBlock({ id: 'image-b', url: '/b.jpg', startLine: 8 })
|
||||||
|
const event = createDragEvent()
|
||||||
|
|
||||||
|
drag.onImageBlockDragStart(event, source)
|
||||||
|
assert.equal(event.dataTransfer.effectAllowed, 'move')
|
||||||
|
assert.equal(JSON.parse(event.dataTransfer.getData(LIVE_IMAGE_DRAG_MIME)).sourceLine, 2)
|
||||||
|
|
||||||
|
drag.onImageBlockDragOver(event, target)
|
||||||
|
assert.equal(event.defaultPrevented, true)
|
||||||
|
assert.deepEqual(drag.imageBlockDropTarget.value, { blockId: 'image-b' })
|
||||||
|
|
||||||
|
drag.onImageBlockDrop(event, target)
|
||||||
|
|
||||||
|
assert.deepEqual(calls.merge, { sourceLine: 2, targetLine: 8 })
|
||||||
|
assert.equal(drag.liveImageDragState.value, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('단일 이미지를 갤러리 이미지 위에 드롭하면 갤러리 내부 삽입 이벤트를 보낸다', () => {
|
||||||
|
const calls = {}
|
||||||
|
const drag = createImageDragState(calls)
|
||||||
|
const source = createImageBlock({ id: 'image-a', url: '/a.jpg', startLine: 2 })
|
||||||
|
const gallery = createGalleryBlock()
|
||||||
|
const event = createDragEvent()
|
||||||
|
|
||||||
|
drag.onImageBlockDragStart(event, source)
|
||||||
|
drag.onGalleryDragOverItem(event, gallery, 1)
|
||||||
|
drag.onGalleryDrop(event, gallery, 1)
|
||||||
|
|
||||||
|
assert.deepEqual(calls.insert, {
|
||||||
|
sourceLine: 2,
|
||||||
|
startLine: 10,
|
||||||
|
endLine: 14,
|
||||||
|
targetIndex: 2,
|
||||||
|
image: {
|
||||||
|
url: '/a.jpg',
|
||||||
|
caption: '',
|
||||||
|
width: undefined,
|
||||||
|
useAlt: false,
|
||||||
|
legacyBracketLabel: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(drag.liveImageDragState.value, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('갤러리 이미지를 같은 갤러리 안에서 드롭하면 이미지 순서 변경 이벤트를 보낸다', () => {
|
||||||
|
const calls = {}
|
||||||
|
const drag = createImageDragState(calls)
|
||||||
|
const gallery = createGalleryBlock()
|
||||||
|
const event = createDragEvent()
|
||||||
|
|
||||||
|
drag.onGalleryDragStart(event, gallery, 0)
|
||||||
|
drag.onGalleryDragOverItem(event, gallery, 2)
|
||||||
|
drag.onGalleryDrop(event, gallery, 2)
|
||||||
|
|
||||||
|
assert.deepEqual(calls.reorder, {
|
||||||
|
startLine: 10,
|
||||||
|
endLine: 14,
|
||||||
|
images: [
|
||||||
|
{ url: '/b.jpg', caption: 'B' },
|
||||||
|
{ url: '/c.jpg', caption: 'C' },
|
||||||
|
{ url: '/a.jpg', caption: 'A' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
assert.equal(drag.liveImageDragState.value, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('갤러리 이미지를 블록 사이와 문서 하단에 드롭하면 단일 이미지 분리 이벤트를 보낸다', () => {
|
||||||
|
const calls = {}
|
||||||
|
const drag = createImageDragState(calls)
|
||||||
|
const gallery = createGalleryBlock()
|
||||||
|
const insertTarget = { id: 'paragraph-a', meta: { startLine: 30 } }
|
||||||
|
const event = createDragEvent()
|
||||||
|
|
||||||
|
drag.onGalleryDragStart(event, gallery, 1)
|
||||||
|
drag.onBlockInsertDragOver(event, insertTarget)
|
||||||
|
assert.deepEqual(drag.blockInsertDropTarget.value, { insertBeforeLine: 30 })
|
||||||
|
|
||||||
|
drag.onBlockInsertDrop(event, insertTarget)
|
||||||
|
assert.deepEqual(calls.extract, {
|
||||||
|
startLine: 10,
|
||||||
|
endLine: 14,
|
||||||
|
imageIndex: 1,
|
||||||
|
insertBeforeLine: 30,
|
||||||
|
image: { url: '/b.jpg', caption: 'B' }
|
||||||
|
})
|
||||||
|
|
||||||
|
drag.onGalleryDragStart(event, gallery, 2)
|
||||||
|
drag.onTailInsertDragOver(event)
|
||||||
|
drag.onTailInsertDrop(event)
|
||||||
|
|
||||||
|
assert.deepEqual(calls.extract, {
|
||||||
|
startLine: 10,
|
||||||
|
endLine: 14,
|
||||||
|
imageIndex: 2,
|
||||||
|
insertBeforeLine: 99,
|
||||||
|
image: { url: '/c.jpg', caption: 'C' }
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user