v1.2.5: 갤러리 드롭 위치 표시 및 파일명 캡션 토글 정리

미리보기 갤러리 드래그 시 드롭 대상 셀을 시각적으로 표시하고, 파일명 토글을 캡션(figcaption) 표시로 맞춤. 미리보기 클릭→작성 모드 전환은 제거.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-15 19:00:26 +09:00
parent c9b484e4c8
commit 0c051cbe3b
7 changed files with 226 additions and 59 deletions

View File

@@ -25,6 +25,8 @@ const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
/** @type {import('vue').Ref<{ blockId: string, imageIndex: number }|null>} */
const galleryDragState = ref(null)
/** @type {import('vue').Ref<{ blockId: string, targetIndex: number }|null>} */
const galleryDropTarget = ref(null)
/**
* 마크다운 블록을 생성
@@ -35,6 +37,23 @@ const galleryDragState = ref(null)
* @param {Object} options - 추가 블록 옵션
* @returns {Object} 블록
*/
/**
* 블록에 원본 마크다운 줄 범위를 붙인다.
* @param {Object} block - 블록
* @param {number} startLine - 시작 줄(0-based)
* @param {number} endLine - 끝 줄(0-based)
* @returns {Object} 블록
*/
const attachSourceRange = (block, startLine, endLine) => {
block.meta = {
...(block.meta && typeof block.meta === 'object' ? block.meta : {}),
startLine,
endLine: endLine ?? startLine
}
return block
}
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
id,
type,
@@ -276,18 +295,21 @@ const parseMarkdownBlocks = (markdown) => {
const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }))
const startLine = index
blocks.push(attachSourceRange(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }), startLine, startLine))
index += 1
continue
}
if (!trimmedLine) {
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
const startLine = index
blocks.push(attachSourceRange(createBlock('spacer', '', null, `block-${blocks.length}`), startLine, startLine))
index += 1
continue
}
if (trimmedLine === '>>>') {
const startLine = index
const contentLines = []
index += 1
@@ -296,17 +318,26 @@ const parseMarkdownBlocks = (markdown) => {
index += 1
}
blocks.push(createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }))
blocks.push(attachSourceRange(
createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }),
startLine,
index - 1
))
index += 1
continue
}
if (trimmedLine === ':::bookmark') {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
if (bookmarkMeta.url) {
blocks.push(createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }))
blocks.push(attachSourceRange(
createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }),
startLine,
nextIndex
))
}
index = nextIndex
@@ -314,14 +345,20 @@ const parseMarkdownBlocks = (markdown) => {
}
if (trimmedLine === ':::signup') {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const signupMeta = parseSignupMeta(contentLines.join('\n'))
blocks.push(createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }))
blocks.push(attachSourceRange(
createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }),
startLine,
nextIndex
))
index = nextIndex
continue
}
if (trimmedLine === ':::gallery') {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const images = []
@@ -332,35 +369,46 @@ const parseMarkdownBlocks = (markdown) => {
}
})
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, {
images,
meta: {
startLine: index,
endLine: nextIndex
}
}))
blocks.push(attachSourceRange(createBlock('gallery', '', null, `block-${blocks.length}`, {
images
}), startLine, nextIndex))
index = nextIndex
continue
}
if (trimmedLine.startsWith(':::callout')) {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
blocks.push(attachSourceRange(
createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)),
startLine,
nextIndex
))
index = nextIndex
continue
}
if (trimmedLine.startsWith(':::toggle')) {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
blocks.push(createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }))
blocks.push(attachSourceRange(
createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }),
startLine,
nextIndex
))
index = nextIndex
continue
}
if (trimmedLine === ':::embed') {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
blocks.push(attachSourceRange(
createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
startLine,
nextIndex
))
index = nextIndex
continue
}
@@ -368,12 +416,14 @@ const parseMarkdownBlocks = (markdown) => {
const image = parseImageLine(trimmedLine)
if (image) {
blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image))
const startLine = index
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, image), startLine, startLine))
index += 1
continue
}
if (trimmedLine.startsWith('```')) {
const startLine = index
const codeLines = []
index += 1
@@ -382,13 +432,18 @@ const parseMarkdownBlocks = (markdown) => {
index += 1
}
blocks.push(createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`))
blocks.push(attachSourceRange(
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`),
startLine,
index
))
index += 1
continue
}
if (trimmedLine === '---') {
blocks.push(createBlock('divider', '', null, `block-${blocks.length}`))
const startLine = index
blocks.push(attachSourceRange(createBlock('divider', '', null, `block-${blocks.length}`), startLine, startLine))
index += 1
continue
}
@@ -396,12 +451,18 @@ const parseMarkdownBlocks = (markdown) => {
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
const startLine = index
blocks.push(attachSourceRange(
createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`),
startLine,
startLine
))
index += 1
continue
}
if (trimmedLine.startsWith('> ')) {
const startLine = index
const quoteLines = []
while (index < lines.length && lines[index].trim().startsWith('>')) {
@@ -409,11 +470,16 @@ const parseMarkdownBlocks = (markdown) => {
index += 1
}
blocks.push(createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`))
blocks.push(attachSourceRange(
createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`),
startLine,
index - 1
))
continue
}
if (/^- /.test(trimmedLine)) {
const startLine = index
const items = []
while (index < lines.length && /^- /.test(lines[index].trim())) {
@@ -421,11 +487,12 @@ const parseMarkdownBlocks = (markdown) => {
index += 1
}
blocks.push(createBlock('list', items, null, `block-${blocks.length}`))
blocks.push(attachSourceRange(createBlock('list', items, null, `block-${blocks.length}`), startLine, index - 1))
continue
}
if (/^\d+\.\s+/.test(trimmedLine)) {
const startLine = index
const items = []
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
@@ -433,10 +500,15 @@ const parseMarkdownBlocks = (markdown) => {
index += 1
}
blocks.push(createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }))
blocks.push(attachSourceRange(
createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }),
startLine,
index - 1
))
continue
}
const paragraphStartLine = index
const paragraphLines = [cleanParagraphLine(line)]
let shouldJoinNextLine = hasMarkdownHardBreak(line)
index += 1
@@ -454,7 +526,11 @@ const parseMarkdownBlocks = (markdown) => {
index += 1
}
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
blocks.push(attachSourceRange(
createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`),
paragraphStartLine,
index - 1
))
}
return blocks
@@ -590,6 +666,51 @@ const onGalleryDragStart = (event, blockId, imageIndex) => {
*/
const onGalleryDragEnd = () => {
galleryDragState.value = null
galleryDropTarget.value = null
}
/**
* 갤러리 드래그 중 드롭 대상 셀 표시
* @param {DragEvent} event - 드래그 이벤트
* @param {Object} block - 갤러리 블록
* @param {number} imageIndex - 호버 중인 이미지 인덱스
* @returns {void}
*/
const onGalleryDragOverItem = (event, block, imageIndex) => {
if (!props.interactive || !galleryDragState.value) {
return
}
event.preventDefault()
if (galleryDragState.value.blockId !== block.id) {
return
}
if (galleryDragState.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
}
}
/**
@@ -624,6 +745,7 @@ const onGalleryDrop = (event, block, targetIndex) => {
})
galleryDragState.value = null
galleryDropTarget.value = null
}
/**
@@ -758,16 +880,21 @@ onBeforeUnmount(() => {
<div
v-else-if="block.type === 'gallery'"
class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3"
@dragleave="onGalleryDragLeaveGallery($event, block)"
>
<figure
v-for="(image, imageIndex) in block.images"
:key="`${block.id}-${imageIndex}-${image.url}`"
class="content-markdown-renderer__gallery-item min-w-0"
:class="interactive ? 'content-markdown-renderer__gallery-item--interactive' : ''"
class="content-markdown-renderer__gallery-item relative min-w-0"
:class="{
'content-markdown-renderer__gallery-item--interactive': interactive,
'content-markdown-renderer__gallery-item--dragging': interactive && galleryDragState?.blockId === block.id && galleryDragState?.imageIndex === imageIndex,
'content-markdown-renderer__gallery-item--drop-target': interactive && galleryDropTarget?.blockId === block.id && galleryDropTarget?.targetIndex === imageIndex
}"
:draggable="interactive"
@dragstart="onGalleryDragStart($event, block.id, imageIndex)"
@dragend="onGalleryDragEnd"
@dragover.prevent
@dragover="onGalleryDragOverItem($event, block, imageIndex)"
@drop="onGalleryDrop($event, block, imageIndex)"
>
<button
@@ -853,4 +980,31 @@ onBeforeUnmount(() => {
.content-markdown-renderer__gallery-item--interactive:active {
cursor: grabbing;
}
.content-markdown-renderer__gallery-item--dragging {
opacity: 0.45;
}
.content-markdown-renderer__gallery-item--drop-target {
outline: 3px solid #ff7a00;
outline-offset: 3px;
border-radius: 12px;
}
.content-markdown-renderer__gallery-item--drop-target::after {
content: '여기로 이동';
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
border-radius: 10px;
background: rgba(255, 122, 0, 0.22);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: -0.02em;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}
</style>