Files
sori.studio/composables/useContentMarkdownImageDrag.js

495 lines
14 KiB
JavaScript

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