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} options.interactive - 라이브 편집 가능 여부 * @param {import('vue').Ref|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 } }