From 5fb2781d72fdef013c377ee0a0ecd8181eeb6d94 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 27 Jul 2026 12:48:10 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EB=B3=B8=EB=AC=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/ContentMarkdownRenderer.vue | 468 ++--------------- composables/useContentMarkdownImageDrag.js | 494 ++++++++++++++++++ docs/history.md | 4 + docs/map.md | 4 +- docs/spec.md | 4 +- docs/todo.md | 2 +- docs/update.md | 5 + package-lock.json | 4 +- package.json | 2 +- tests/useContentMarkdownImageDrag.test.js | 218 ++++++++ 10 files changed, 761 insertions(+), 444 deletions(-) create mode 100644 composables/useContentMarkdownImageDrag.js create mode 100644 tests/useContentMarkdownImageDrag.test.js diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index ea61b17..ee2df67 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -43,6 +43,7 @@ import { import { getGalleryRows } from '../../lib/content-gallery-images.js' import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js' import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js' +import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js' import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue' import ProseCodeBlock from './ProseCodeBlock.vue' import ProseHtml from './ProseHtml.vue' @@ -95,20 +96,10 @@ const emit = defineEmits([ 'content-replace' ]) -/** @type {string} 라이브 이미지·갤러리 드래그 payload MIME */ -const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image' /** @type {string} 본문 리스트 마커 색상 */ const CONTENT_LIST_MARKER_COLOR = '#2eb6ea' /** @type {import('vue').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} */ const pendingFocusLine = ref(null) /** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */ @@ -143,6 +134,36 @@ const { 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 - 렌더링 블록 @@ -1582,433 +1603,6 @@ const openContentLightbox = (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() -} -