v1.4.2: 라이브 이미지·갤러리 편집 UX와 공개 화면 색상 정리

라이브 모드 이미지·갤러리 드래그 병합·분리, 갤러리 개별 편집, 블록 패널 유지, 다크모드 인용·사이드바·리스트 마커 색상을 보정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-21 17:07:52 +09:00
parent 095a8fa5f0
commit 6919669330
14 changed files with 1551 additions and 91 deletions

View File

@@ -133,6 +133,7 @@ const onPanelFocusOut = (event) => {
v-for="(image, imageIndex) in panel.images"
:key="`block-panel-image-${imageIndex}`"
class="admin-editor-block-panel__media-row grid gap-3 rounded border border-[#edf0f2] bg-[#fafafa] p-3"
:class="panel.selectedImageIndex === imageIndex ? 'admin-editor-block-panel__media-row--selected' : ''"
>
<img
class="aspect-[16/10] w-full rounded bg-[#eff1f2] object-cover"
@@ -220,3 +221,10 @@ const onPanelFocusOut = (event) => {
</div>
</aside>
</template>
<style scoped>
.admin-editor-block-panel__media-row--selected {
border-color: #2eb6ea;
box-shadow: 0 0 0 2px rgba(46, 182, 234, 0.18);
}
</style>

View File

@@ -1,7 +1,12 @@
<script setup>
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
import { getImageDefaultAltLabel, serializeImageMarkdown } from '../../lib/markdown-image.js'
import {
getImageDefaultAltLabel,
isImageUrl,
parseImageMarkdownLine,
serializeImageMarkdown
} from '../../lib/markdown-image.js'
import { convertHtmlToMarkdown } from '../../lib/markdown-inline.js'
import {
filterSlashCommands,
@@ -52,6 +57,12 @@ const previewRendererRef = ref(null)
const gutterRef = ref(null)
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
const activeLogicalLineIndex = ref(0)
/** textarea 자동 줄바꿈을 반영한 논리 줄별 표시 높이(px) */
const gutterLineHeights = ref([])
/** 라이브 모드에서 소스 모드로 돌아올 때 복원할 줄(0-based) */
const pendingWriteFocusLine = ref(null)
/** 소스 모드에서 라이브 모드로 넘어갈 때 복원할 줄·오프셋 */
const pendingPreviewFocus = ref(null)
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
@@ -128,6 +139,15 @@ const isFocusInBlockPanel = () => Boolean(
&& document.activeElement?.closest?.('.admin-editor-block-panel')
)
/**
* 현재 포커스가 미디어 선택 모달 안에 있는지 확인한다.
* @returns {boolean}
*/
const isFocusInMediaPicker = () => Boolean(
typeof document !== 'undefined'
&& document.activeElement?.closest?.('.admin-markdown-editor__media-modal')
)
/**
* 블록 패널 편집 중 상태를 유지한다.
* @returns {void}
@@ -138,6 +158,17 @@ const ensureBlockPanelEngaged = () => {
syncBlockPanelState()
}
/**
* 블록 설정 패널을 닫는다.
* @returns {void}
*/
const closeBlockPanel = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = false
isTextareaFocused.value = false
syncBlockPanelState()
}
/**
* 커서 줄 기준 활성 블록(이미지·갤러리·임베드)
* @returns {Object|null}
@@ -154,7 +185,7 @@ const activeMediaBlock = activeBlockContext
* 블록 설정 패널 표시 여부
* @returns {boolean}
*/
const isBlockPanelVisible = computed(() => activeMode.value === 'write'
const isBlockPanelVisible = computed(() => (activeMode.value === 'write' || isBlockPanelEngaged.value)
&& Boolean(activeBlockContext.value)
&& (isTextareaFocused.value || isBlockPanelEngaged.value))
@@ -182,6 +213,119 @@ const gutterLineCount = computed(() => {
return Math.max(1, n)
})
/**
* CSS px 값을 숫자로 변환한다.
* @param {string} value - CSS 값
* @param {number} fallback - 변환 실패 시 기본값
* @returns {number} px 숫자
*/
const parseCssPixelValue = (value, fallback) => {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : fallback
}
/**
* 소스 textarea의 줄 시작 오프셋을 반환한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {number} 문자 오프셋
*/
const getLineStartOffset = (lineIndex) => {
const lines = (markdownValue.value ?? '').split('\n')
const safeLineIndex = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1))
return lines.slice(0, safeLineIndex).join('\n').length + (safeLineIndex > 0 ? 1 : 0)
}
/**
* 문자 오프셋이 속한 소스 줄 번호를 반환한다.
* @param {number} offset - 문자 오프셋
* @returns {number} 줄 번호(0-based)
*/
const getLineIndexAtOffset = (offset) => {
const value = markdownValue.value ?? ''
const safeOffset = Math.min(Math.max(0, offset), value.length)
return Math.max(0, value.slice(0, safeOffset).split('\n').length - 1)
}
/**
* 현재 textarea 선택 위치를 라이브 모드 포커스 대상으로 저장한다.
* @returns {void}
*/
const rememberWritePositionForPreview = () => {
const textarea = textareaRef.value
const value = markdownValue.value ?? ''
const start = textarea
? Math.min(textarea.selectionStart, value.length)
: Math.min(lastSelectionState.value.start, value.length)
const line = getLineIndexAtOffset(start)
pendingPreviewFocus.value = {
line,
offset: Math.max(0, start - getLineStartOffset(line))
}
}
/**
* textarea와 동일한 폭·폰트로 각 논리 줄의 실제 wrap 높이를 측정한다.
* @returns {void}
*/
const syncGutterLineHeights = () => {
if (!import.meta.client) {
return
}
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) {
gutterLineHeights.value = []
return
}
const style = window.getComputedStyle(textarea)
const fallbackLineHeight = parseCssPixelValue(style.lineHeight, 28)
const paddingLeft = parseCssPixelValue(style.paddingLeft, 0)
const paddingRight = parseCssPixelValue(style.paddingRight, 0)
const contentWidth = Math.max(1, textarea.clientWidth - paddingLeft - paddingRight)
const mirror = document.createElement('div')
mirror.style.position = 'absolute'
mirror.style.left = '-10000px'
mirror.style.top = '0'
mirror.style.visibility = 'hidden'
mirror.style.pointerEvents = 'none'
mirror.style.width = `${contentWidth}px`
mirror.style.boxSizing = 'border-box'
mirror.style.whiteSpace = 'pre-wrap'
mirror.style.overflowWrap = style.overflowWrap || 'break-word'
mirror.style.wordBreak = style.wordBreak || 'normal'
mirror.style.font = style.font
mirror.style.letterSpacing = style.letterSpacing
mirror.style.lineHeight = style.lineHeight
document.body.appendChild(mirror)
const nextHeights = (markdownValue.value ?? '').split('\n').map((line) => {
const lineEl = document.createElement('div')
lineEl.textContent = line || ' '
mirror.appendChild(lineEl)
return Math.max(fallbackLineHeight, lineEl.getBoundingClientRect().height)
})
document.body.removeChild(mirror)
gutterLineHeights.value = nextHeights.length ? nextHeights : [fallbackLineHeight]
})
}
/**
* 거터 줄 높이를 반환한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {number} 줄 높이(px)
*/
const getGutterLineHeight = (lineIndex) => gutterLineHeights.value[lineIndex] || 28
/**
* textarea 높이를 본문 길이에 맞춘다. 내부 스크롤 없이 부모(`editor-scroll`)만 스크롤한다.
* @returns {void}
@@ -196,6 +340,7 @@ const syncTextareaHeight = () => {
textarea.style.height = '0px'
textarea.style.height = `${Math.max(MIN_TEXTAREA_HEIGHT_PX, textarea.scrollHeight)}px`
syncGutterLineHeights()
})
}
@@ -215,7 +360,7 @@ const onTextareaFocus = () => {
*/
const onTextareaBlur = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (isBlockPanelEngaged.value || isFocusInBlockPanel()) {
if (isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
@@ -240,7 +385,7 @@ const handleBlockPanelFocusIn = () => {
*/
const handleBlockPanelFocusOut = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (isFocusInBlockPanel()) {
if (isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
@@ -338,6 +483,102 @@ const rememberTextareaSelection = () => {
}
}
/**
* 가장 가까운 스크롤 컨테이너를 찾는다.
* @param {HTMLElement|null} element - 기준 요소
* @returns {HTMLElement|null} 스크롤 컨테이너
*/
const getNearestScrollContainer = (element) => {
let current = element?.parentElement || null
while (current && current !== document.body && current !== document.documentElement) {
const style = window.getComputedStyle(current)
if (/(auto|scroll)/.test(`${style.overflowY} ${style.overflow}`) && current.scrollHeight > current.clientHeight) {
return current
}
current = current.parentElement
}
return null
}
/**
* textarea의 지정 줄이 화면 안에 보이도록 스크롤한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {void}
*/
const scrollTextareaLineIntoView = (lineIndex) => {
const textarea = textareaRef.value
if (!textarea || !import.meta.client) {
return
}
const style = window.getComputedStyle(textarea)
const paddingTop = parseCssPixelValue(style.paddingTop, 0)
const lineTop = paddingTop + gutterLineHeights.value
.slice(0, Math.max(0, lineIndex))
.reduce((sum, height) => sum + height, 0)
const lineHeight = getGutterLineHeight(lineIndex)
const lineBottom = lineTop + lineHeight
const padding = 140
const scrollContainer = getNearestScrollContainer(editorRootRef.value)
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect()
const textareaRect = textarea.getBoundingClientRect()
const absoluteLineTop = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineTop
const absoluteLineBottom = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineBottom
const viewTop = scrollContainer.scrollTop
const viewBottom = viewTop + scrollContainer.clientHeight
if (absoluteLineTop < viewTop + padding) {
scrollContainer.scrollTop = Math.max(0, absoluteLineTop - padding)
} else if (absoluteLineBottom > viewBottom - padding) {
scrollContainer.scrollTop = Math.max(0, absoluteLineBottom - scrollContainer.clientHeight + padding)
}
return
}
const textareaRect = textarea.getBoundingClientRect()
const absoluteLineTop = textareaRect.top + window.scrollY + lineTop
const absoluteLineBottom = textareaRect.top + window.scrollY + lineBottom
const viewTop = window.scrollY
const viewBottom = viewTop + window.innerHeight
if (absoluteLineTop < viewTop + padding) {
window.scrollTo({ top: Math.max(0, absoluteLineTop - padding) })
} else if (absoluteLineBottom > viewBottom - padding) {
window.scrollTo({ top: Math.max(0, absoluteLineBottom - window.innerHeight + padding) })
}
}
/**
* 소스 textarea의 지정 줄로 포커스와 커서를 이동한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {void}
*/
const focusTextareaAtLine = (lineIndex) => {
nextTick(() => {
const textarea = textareaRef.value
const value = markdownValue.value ?? ''
if (!textarea) {
return
}
const offset = Math.min(getLineStartOffset(lineIndex), value.length)
textarea.focus()
textarea.setSelectionRange(offset, offset)
syncTextareaHeight()
nextTick(() => scrollTextareaLineIntoView(lineIndex))
refreshCaretLogicalLine()
})
}
/**
* 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다.
* @returns {void}
@@ -353,11 +594,12 @@ const restoreTextareaFocus = () => {
const start = Math.min(lastSelectionState.value.start, value.length)
const end = Math.min(lastSelectionState.value.end, value.length)
const lineIndex = value.slice(0, start).split('\n').length - 1
textarea.focus()
textarea.setSelectionRange(start, end)
syncTextareaHeight()
textarea.scrollIntoView({ block: 'nearest', inline: 'nearest' })
nextTick(() => scrollTextareaLineIntoView(lineIndex))
refreshCaretLogicalLine()
})
}
@@ -375,17 +617,30 @@ watch(activeMode, (mode) => {
if (mode === 'write') {
nextTick(() => {
syncTextareaHeight()
restoreTextareaFocus()
const focusLine = pendingWriteFocusLine.value
pendingWriteFocusLine.value = null
if (textareaRef.value) {
textareaRef.value.scrollTop = 0
if (typeof focusLine === 'number') {
focusTextareaAtLine(focusLine)
} else {
restoreTextareaFocus()
}
})
return
}
nextTick(() => {
scrollPreviewToTop()
const previewFocus = pendingPreviewFocus.value
pendingPreviewFocus.value = null
if (!previewFocus) {
scrollPreviewToTop()
return
}
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset)
})
})
})
@@ -399,6 +654,48 @@ const scrollPreviewToTop = () => {
}
}
/**
* 라이브 미리보기에서 현재 포커스 또는 화면 상단에 가까운 원본 줄을 찾는다.
* @returns {number|null} 원본 줄 번호
*/
const getCurrentPreviewSourceLine = () => {
if (!import.meta.client || !previewRef.value) {
return null
}
const activeElement = document.activeElement
const focusedLineElement = previewRef.value.contains(activeElement)
? activeElement?.closest?.('[data-source-line]')
: null
const focusedLine = Number(focusedLineElement?.getAttribute?.('data-source-line'))
if (Number.isFinite(focusedLine)) {
return focusedLine
}
const previewRect = previewRef.value.getBoundingClientRect()
const anchorTop = Math.max(previewRect.top, 0) + 24
const candidates = [...previewRef.value.querySelectorAll('[data-source-line]')]
.map((element) => {
const rect = element.getBoundingClientRect()
const line = Number(element.getAttribute('data-source-line'))
return { rect, line }
})
.filter(({ rect, line }) => Number.isFinite(line) && rect.height > 0 && rect.bottom >= anchorTop)
.sort((a, b) => Math.abs(a.rect.top - anchorTop) - Math.abs(b.rect.top - anchorTop))
return candidates[0]?.line ?? null
}
/**
* 라이브 모드의 현재 위치를 소스 모드 복원 대상으로 저장한다.
* @returns {void}
*/
const rememberPreviewPosition = () => {
pendingWriteFocusLine.value = getCurrentPreviewSourceLine() ?? activeLogicalLineIndex.value
}
/**
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
* @returns {number} 줄 번호(0-based)
@@ -442,6 +739,9 @@ const ensureContentFocusLineExists = (lineIndex) => {
const toggleEditorMode = () => {
if (activeMode.value === 'write') {
rememberTextareaSelection()
rememberWritePositionForPreview()
} else {
rememberPreviewPosition()
}
activeMode.value = activeMode.value === 'write' ? 'preview' : 'write'
@@ -459,6 +759,9 @@ const setEditorMode = (mode) => {
if (activeMode.value === 'write') {
rememberTextareaSelection()
rememberWritePositionForPreview()
} else {
rememberPreviewPosition()
}
activeMode.value = mode
@@ -549,17 +852,47 @@ onMounted(() => {
toggleEditorMode()
}
/**
* 블록 패널 바깥 클릭 시 패널을 닫는다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const onDocumentPointerDown = (event) => {
if (!isBlockPanelVisible.value || isMediaPickerOpen.value) {
return
}
const target = event.target
if (!(target instanceof Element)) {
return
}
if (target.closest('.admin-editor-block-panel')
|| target.closest('.admin-markdown-editor')
|| target.closest('.admin-markdown-editor__media-modal')) {
return
}
closeBlockPanel()
}
document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('keydown', onDocumentKeydown, true)
document.addEventListener('pointerdown', onDocumentPointerDown, true)
window.addEventListener('resize', syncGutterLineHeights)
onBeforeUnmount(() => {
window.clearTimeout(blockPanelFocusTimer)
window.clearTimeout(liveSlashKeyboardNavTimer)
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('keydown', onDocumentKeydown, true)
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
window.removeEventListener('resize', syncGutterLineHeights)
})
refreshCaretLogicalLine()
syncGutterLineHeights()
syncBlockPanelState()
})
@@ -584,8 +917,7 @@ const focusFirstBlock = () => {
nextTick(() => {
const textarea = textareaRef.value
const lines = (markdownValue.value ?? '').split('\n')
const lineStart = lines.slice(0, focusLine).join('\n').length + (focusLine > 0 ? 1 : 0)
const lineStart = getLineStartOffset(focusLine)
if (textarea) {
textarea.focus()
@@ -747,6 +1079,32 @@ const insertCodeBlock = () => {
*/
const createImageMarkdown = (image) => serializeImageMarkdown(image)
/**
* 마크다운 이미지 줄과 단독 이미지 URL 줄을 같은 이미지 데이터로 해석한다.
* @param {string} line - 원본 마크다운 줄
* @returns {{ url: string, width: string, caption: string, useAlt: boolean }|null} 이미지 데이터
*/
const parseEditorImageLine = (line) => {
const image = parseImageMarkdownLine(line)
if (image) {
return image
}
const trimmed = String(line || '').trim()
if (!isImageUrl(trimmed)) {
return null
}
return {
url: trimmed,
width: 'regular',
caption: '',
useAlt: false
}
}
/**
* 파일 크기를 표시용 문자열로 변환한다.
* @param {number} size - 바이트 크기
@@ -982,6 +1340,196 @@ const onPreviewGalleryReorder = ({ startLine, endLine, images }) => {
], false)
}
/**
* 라이브 모드에서 두 이미지 블록을 갤러리 fenced block으로 병합한다.
* @param {{ sourceLine: number, targetLine: number }} payload - 원본·대상 줄(0-based)
* @returns {void}
*/
const onPreviewMergeImagesToGallery = ({ sourceLine, targetLine }) => {
if (typeof sourceLine !== 'number' || typeof targetLine !== 'number' || sourceLine === targetLine) {
return
}
const lines = (markdownValue.value || '').split('\n')
const firstLine = Math.min(sourceLine, targetLine)
const secondLine = Math.max(sourceLine, targetLine)
const firstImage = parseEditorImageLine(lines[firstLine] || '')
const secondImage = parseEditorImageLine(lines[secondLine] || '')
if (!firstImage || !secondImage) {
return
}
const orderedImages = sourceLine < targetLine
? [firstImage, secondImage]
: [secondImage, firstImage]
const nextLines = [
...lines.slice(0, firstLine),
':::gallery',
...orderedImages.map(createImageMarkdown),
':::',
...lines.slice(secondLine + 1)
]
markdownValue.value = nextLines.join('\n')
}
/**
* 라이브 모드에서 단일 이미지 블록을 기존 갤러리에 추가한다.
* @param {{ sourceLine: number, startLine: number, endLine: number, targetIndex: number, image?: Object }} payload - 이미지 줄·갤러리 범위·삽입 위치
* @returns {void}
*/
const onPreviewInsertImageToGallery = ({
sourceLine,
startLine,
endLine,
targetIndex,
image
}) => {
if (typeof sourceLine !== 'number'
|| typeof startLine !== 'number'
|| typeof endLine !== 'number'
|| typeof targetIndex !== 'number'
|| sourceLine >= startLine && sourceLine <= endLine) {
return
}
const lines = (markdownValue.value || '').split('\n')
const sourceImage = image && image.url ? image : parseEditorImageLine(lines[sourceLine] || '')
const galleryImages = lines
.slice(startLine + 1, endLine)
.map((line) => parseEditorImageLine(line))
.filter(Boolean)
if (!sourceImage || !galleryImages.length) {
return
}
const nextGalleryImages = [...galleryImages]
const insertIndex = Math.max(0, Math.min(targetIndex, nextGalleryImages.length))
nextGalleryImages.splice(insertIndex, 0, sourceImage)
const withoutSource = [
...lines.slice(0, sourceLine),
...lines.slice(sourceLine + 1)
]
const adjustedStartLine = startLine - (sourceLine < startLine ? 1 : 0)
const adjustedEndLine = endLine - (sourceLine < endLine ? 1 : 0)
markdownValue.value = [
...withoutSource.slice(0, adjustedStartLine),
':::gallery',
...nextGalleryImages.map(createImageMarkdown),
':::',
...withoutSource.slice(adjustedEndLine + 1)
].join('\n')
}
/**
* 라이브 모드 갤러리에서 특정 이미지를 삭제한다.
* @param {{ startLine: number, endLine: number, imageIndex: number }} payload - 갤러리 범위·이미지 인덱스
* @returns {void}
*/
const onPreviewRemoveGalleryImage = ({ startLine, endLine, imageIndex }) => {
if (typeof startLine !== 'number' || typeof endLine !== 'number' || typeof imageIndex !== 'number') {
return
}
const lines = (markdownValue.value || '').split('\n')
const galleryImages = lines
.slice(startLine + 1, endLine)
.map((line) => parseEditorImageLine(line))
.filter(Boolean)
if (imageIndex < 0 || imageIndex >= galleryImages.length) {
return
}
const remaining = galleryImages.filter((_, index) => index !== imageIndex)
const replacementLines = remaining.length === 0
? []
: remaining.length === 1
? [createImageMarkdown(remaining[0])]
: [
':::gallery',
...remaining.map(createImageMarkdown),
':::'
]
replaceLineRange(startLine, endLine, replacementLines, false)
activeLogicalLineIndex.value = Math.max(0, Math.min(startLine + imageIndex, startLine + replacementLines.length - 1))
}
/**
* 라이브 모드에서 갤러리 이미지를 블록 사이에 단일 이미지 줄로 분리한다.
* @param {{ startLine: number, endLine: number, imageIndex: number, insertBeforeLine: number, image?: Object }} payload - 갤러리 범위·삽입 위치
* @returns {void}
*/
const onPreviewExtractGalleryImage = ({
startLine,
endLine,
imageIndex,
insertBeforeLine,
image
}) => {
if (typeof startLine !== 'number'
|| typeof endLine !== 'number'
|| typeof imageIndex !== 'number'
|| typeof insertBeforeLine !== 'number') {
return
}
const lines = (markdownValue.value || '').split('\n')
const galleryImages = lines
.slice(startLine + 1, endLine)
.map((line) => parseEditorImageLine(line))
.filter(Boolean)
if (imageIndex < 0 || imageIndex >= galleryImages.length) {
return
}
const extracted = image && image.url ? image : galleryImages[imageIndex]
const remaining = galleryImages.filter((_, index) => index !== imageIndex)
const galleryReplacement = remaining.length === 0
? []
: remaining.length === 1
? [createImageMarkdown(remaining[0])]
: [
':::gallery',
...remaining.map(createImageMarkdown),
':::'
]
const withoutGallery = [
...lines.slice(0, startLine),
...lines.slice(endLine + 1)
]
const removedSpan = endLine - startLine + 1
const addedSpan = galleryReplacement.length
let insertAt = insertBeforeLine
if (insertBeforeLine > endLine) {
insertAt -= removedSpan - addedSpan
} else if (insertBeforeLine > startLine) {
insertAt = startLine + addedSpan
}
insertAt = Math.max(0, Math.min(insertAt, withoutGallery.length))
withoutGallery.splice(insertAt, 0, createImageMarkdown(extracted))
let galleryAt = startLine
if (startLine > insertAt) {
galleryAt += 1
}
withoutGallery.splice(galleryAt, 0, ...galleryReplacement)
markdownValue.value = withoutGallery.join('\n')
}
/**
* 미리보기 인라인 편집 결과를 마크다운 본문에 반영한다.
* @param {{ startLine: number, endLine: number, replacementLines: string[] }} payload - 줄 범위·대체 줄
@@ -1047,6 +1595,20 @@ const onPreviewDeleteLine = (lineIndex) => {
markdownValue.value = nextLines.length ? nextLines.join('\n') : ''
}
/**
* 라이브 모드 이미지 설정 패널을 연다.
* @param {number} lineIndex - 이미지 원본 줄 번호(0-based)
* @returns {void}
*/
const onPreviewEditImage = (lineIndex) => {
if (typeof lineIndex !== 'number' || lineIndex < 0) {
return
}
activeLogicalLineIndex.value = lineIndex
ensureBlockPanelEngaged()
}
/**
* 라이브 모드에서 현재 줄을 이전 줄 끝에 병합한다.
* @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄
@@ -1554,6 +2116,10 @@ const fetchMediaItems = async () => {
* @returns {Promise<void>}
*/
const openMediaPicker = async (target) => {
if (target === 'active-gallery') {
ensureBlockPanelEngaged()
}
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'library'
mediaSearchQuery.value = ''
@@ -2058,7 +2624,8 @@ const handleKeydown = (event) => {
<div
v-for="ln in gutterLineCount"
:key="`gutter-line-${ln}`"
class="admin-markdown-editor__gutter-line min-h-[28px] text-right tabular-nums"
class="admin-markdown-editor__gutter-line text-right tabular-nums"
:style="{ height: `${getGutterLineHeight(ln - 1)}px` }"
>
{{ ln }}
</div>
@@ -2101,11 +2668,16 @@ const handleKeydown = (event) => {
:slash-menu-active="liveSlashVisible"
:slash-suppressed-lines="liveSlashSuppressedLineList"
@gallery-reorder="onPreviewGalleryReorder"
@merge-images-to-gallery="onPreviewMergeImagesToGallery"
@insert-image-to-gallery="onPreviewInsertImageToGallery"
@extract-gallery-image="onPreviewExtractGalleryImage"
@remove-gallery-image="onPreviewRemoveGalleryImage"
@block-content-change="onPreviewBlockContentChange"
@append-paragraph="onPreviewAppendParagraph"
@insert-after-line="onPreviewInsertAfterLine"
@delete-line="onPreviewDeleteLine"
@merge-with-previous-line="onPreviewMergeWithPreviousLine"
@edit-image="onPreviewEditImage"
@slash-update="onLiveSlashUpdate"
@slash-end="onLiveSlashEnd"
@slash-apply="onLiveSlashApply"

View File

@@ -2,6 +2,7 @@
import {
getImageAltAttribute,
getImageDisplayCaption,
isImageUrl,
parseImageMarkdownLine
} from '../../lib/markdown-image.js'
import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
@@ -49,24 +50,39 @@ const props = defineProps({
const emit = defineEmits([
'gallery-reorder',
'merge-images-to-gallery',
'insert-image-to-gallery',
'extract-gallery-image',
'remove-gallery-image',
'block-content-change',
'append-paragraph',
'insert-after-line',
'delete-line',
'merge-with-previous-line',
'edit-image',
'slash-update',
'slash-end',
'slash-apply'
])
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
/** @type {string} 라이브 이미지·갤러리 드래그 payload MIME */
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
/** @type {string} 본문 리스트 마커 색상 */
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
/** @type {import('vue').Ref<{ blockId: string, imageIndex: number }|null>} */
const galleryDragState = ref(null)
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
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>} */
const pendingFocusLine = ref(null)
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
@@ -198,6 +214,13 @@ const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
*/
const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim())
/**
* 단독 이미지 URL 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} 이미지 URL 여부
*/
const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageUrl(line)
/**
* 빈 줄 공백 블록 높이를 반환한다.
* @param {Object} block - 렌더링 블록
@@ -526,6 +549,16 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (isStandaloneImageUrlLine(trimmedLine)) {
const startLine = index
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, {
url: trimmedLine,
width: 'regular'
}), startLine, startLine))
index += 1
continue
}
if (isStandaloneUrlLine(trimmedLine)) {
const startLine = index
blocks.push(attachSourceRange(
@@ -651,8 +684,82 @@ const parseMarkdownBlocks = (markdown) => {
}
const blocks = computed(() => parseMarkdownBlocks(props.content))
/** @type {import('vue').ComputedRef<number>} 문서 맨 아래 단일 이미지 삽입 줄 */
const tailInsertBeforeLine = computed(() => {
const lastBlock = blocks.value[blocks.value.length - 1]
if (typeof lastBlock?.meta?.endLine === 'number') {
return lastBlock.meta.endLine + 1
}
return (props.content || '').split('\n').length
})
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
/**
* 갤러리 이미지를 행 단위로 최대 3개씩 나눈다.
* @param {Array<Object>} images - 갤러리 이미지 목록
* @returns {Array<{ startIndex: number, images: Array<Object> }>} 행 목록
*/
const getGalleryRows = (images) => {
const rows = []
for (let index = 0; index < images.length; index += 3) {
rows.push({
startIndex: index,
images: images.slice(index, index + 3)
})
}
return rows
}
/**
* 갤러리 이미지 비율 저장 키를 만든다.
* @param {string} blockId - 블록 ID
* @param {number} imageIndex - 이미지 인덱스
* @param {string} url - 이미지 URL
* @returns {string} 키
*/
const getGalleryImageAspectKey = (blockId, imageIndex, url) => `${blockId}:${imageIndex}:${url}`
/**
* 갤러리 이미지 로드 후 자연 비율을 저장한다.
* @param {Event} event - 이미지 로드 이벤트
* @param {string} blockId - 블록 ID
* @param {number} imageIndex - 이미지 인덱스
* @param {string} url - 이미지 URL
* @returns {void}
*/
const onGalleryImageLoad = (event, blockId, imageIndex, url) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || !image.naturalWidth || !image.naturalHeight) {
return
}
galleryImageAspectRatios.value = {
...galleryImageAspectRatios.value,
[getGalleryImageAspectKey(blockId, imageIndex, url)]: image.naturalWidth / image.naturalHeight
}
}
/**
* 갤러리 이미지 셀의 flex 비율 스타일을 만든다.
* @param {Object} block - 갤러리 블록
* @param {Object} image - 이미지 데이터
* @param {number} imageIndex - 이미지 인덱스
* @returns {Record<string, string>} 스타일
*/
const getGalleryItemStyle = (block, image, imageIndex) => {
const aspect = galleryImageAspectRatios.value[getGalleryImageAspectKey(block.id, imageIndex, image.url)] || 1
return {
flex: `${Math.max(0.45, Math.min(aspect, 3))} 1 0`
}
}
watch(() => props.content, () => {
if (pendingFocusLine.value === null) {
return
@@ -1225,6 +1332,79 @@ const deletePreviewCardBlock = (block) => {
})
}
/**
* 라이브 이미지 설정 패널을 요청한다.
* @param {Object} block - 이미지 블록
* @returns {void}
*/
const editImageBlock = (block) => {
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
return
}
emit('edit-image', block.meta.startLine)
}
/**
* 라이브 갤러리의 특정 이미지 설정 패널을 요청한다.
* @param {Object} block - 갤러리 블록
* @param {number} imageIndex - 이미지 인덱스
* @returns {void}
*/
const editGalleryImage = (block, imageIndex) => {
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
return
}
emit('edit-image', block.meta.startLine + imageIndex + 1)
}
/**
* 라이브 갤러리에서 특정 이미지만 삭제한다.
* @param {Object} block - 갤러리 블록
* @param {number} imageIndex - 이미지 인덱스
* @returns {void}
*/
const removeGalleryImage = (block, imageIndex) => {
if (!props.interactive || typeof block.meta?.startLine !== 'number' || typeof block.meta?.endLine !== 'number') {
return
}
emit('remove-gallery-image', {
startLine: block.meta.startLine,
endLine: block.meta.endLine,
imageIndex
})
}
/**
* 갤러리 카드 키보드 이동을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @param {Object} block - 갤러리 블록
* @returns {void}
*/
const onGalleryBlockKeydown = (event, block) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
event.stopPropagation()
moveFromPreviewCardBlock(block, 1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
moveFromPreviewCardBlock(block, -1)
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
onInsertBelowBlock(block)
}
}
/**
* 선택형 프리뷰 카드 블록 키보드 조작을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
@@ -1559,20 +1739,324 @@ const showNextImage = () => {
}
/**
* 갤러리 드래그 시작
* 블록 앞 삽입 위치(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 {string} blockId - 블록 ID
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} payload - 드래그 payload
* @returns {void}
*/
const onGalleryDragStart = (event, blockId, imageIndex) => {
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
}
galleryDragState.value = { blockId, imageIndex }
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(imageIndex))
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)
}
/**
@@ -1580,8 +2064,7 @@ const onGalleryDragStart = (event, blockId, imageIndex) => {
* @returns {void}
*/
const onGalleryDragEnd = () => {
galleryDragState.value = null
galleryDropTarget.value = null
clearLiveImageDragUi()
}
/**
@@ -1592,17 +2075,25 @@ const onGalleryDragEnd = () => {
* @returns {void}
*/
const onGalleryDragOverItem = (event, block, imageIndex) => {
if (!props.interactive || !galleryDragState.value) {
if (!props.interactive || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
return
}
event.preventDefault()
blockInsertDropTarget.value = null
if (galleryDragState.value.blockId !== block.id) {
if (liveImageDragState.value.kind === 'image') {
event.dataTransfer.dropEffect = 'move'
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
imageBlockDropTarget.value = null
return
}
if (galleryDragState.value.imageIndex === imageIndex) {
if (liveImageDragState.value.blockId !== block.id) {
return
}
if (liveImageDragState.value.imageIndex === imageIndex) {
galleryDropTarget.value = null
return
}
@@ -1636,16 +2127,28 @@ const onGalleryDragLeaveGallery = (event, block) => {
* @returns {void}
*/
const onGalleryDrop = (event, block, targetIndex) => {
if (!props.interactive || !galleryDragState.value) {
if (!props.interactive || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
return
}
event.preventDefault()
const { blockId, imageIndex: fromIndex } = galleryDragState.value
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) {
galleryDragState.value = null
clearLiveImageDragUi()
return
}
@@ -1659,8 +2162,7 @@ const onGalleryDrop = (event, block, targetIndex) => {
images
})
galleryDragState.value = null
galleryDropTarget.value = null
clearLiveImageDragUi()
}
/**
@@ -1717,6 +2219,14 @@ onBeforeUnmount(() => {
<template>
<div ref="rendererRootRef" class="content-markdown-renderer">
<template v-for="block in blocks" :key="block.id">
<div
v-if="interactive && getBlockInsertBeforeLine(block) !== null"
class="content-markdown-renderer__block-insert"
:class="{ 'content-markdown-renderer__block-insert--active': blockInsertDropTarget?.insertBeforeLine === getBlockInsertBeforeLine(block) }"
@dragover="onBlockInsertDragOver($event, block)"
@dragleave="onBlockInsertDragLeave"
@drop="onBlockInsertDrop($event, block)"
/>
<ContentMarkdownEditableInline
v-if="block.type === 'spacer' && interactive"
tag="p"
@@ -1844,6 +2354,52 @@ onBeforeUnmount(() => {
</span>
</li>
</ProseList>
<section
v-else-if="block.type === 'image' && interactive"
class="content-markdown-renderer__image-live group relative rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
:class="{
'content-markdown-renderer__image-live--dragging': liveImageDragState?.kind === 'image' && liveImageDragState?.blockId === block.id,
'content-markdown-renderer__image-live--drop-target': imageBlockDropTarget?.blockId === block.id
}"
:data-source-line="block.meta.startLine"
data-preview-card-block="true"
tabindex="0"
role="group"
aria-label="이미지 블록"
draggable="true"
@dragstart="onImageBlockDragStart($event, block)"
@dragend="onImageBlockDragEnd"
@dragover="onImageBlockDragOver($event, block)"
@dragleave="onImageBlockDragLeave($event, block)"
@drop="onImageBlockDrop($event, block)"
@mousedown.capture="focusPreviewCardBlock"
@keydown="onPreviewCardKeydown($event, block)"
>
<div class="content-markdown-renderer__image-actions absolute right-2 top-2 z-10 flex gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
class="content-markdown-renderer__image-edit content-markdown-renderer__preview-card-action rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white shadow transition-colors hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)]"
type="button"
aria-label="이미지 편집"
@click.stop="editImageBlock(block)"
>
편집
</button>
<button
class="content-markdown-renderer__image-delete content-markdown-renderer__preview-card-action rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white shadow transition-colors hover:bg-red-600 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)]"
type="button"
aria-label="이미지 삭제"
@click.stop="deletePreviewCardBlock(block)"
>
삭제
</button>
</div>
<ProseImage
:src="block.url"
:alt="getImageAltAttribute(block)"
:caption="getImageDisplayCaption(block)"
:variant="block.width"
/>
</section>
<ProseImage
v-else-if="block.type === 'image'"
:src="block.url"
@@ -1992,42 +2548,79 @@ onBeforeUnmount(() => {
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
<div
v-else-if="block.type === 'gallery'"
class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3"
class="content-markdown-renderer__gallery group relative my-8 flex flex-col gap-2 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
:data-source-line="block.meta.startLine"
:data-preview-card-block="interactive ? 'true' : undefined"
:tabindex="interactive ? 0 : undefined"
role="group"
aria-label="갤러리 블록"
@mousedown.capture="interactive ? focusPreviewCardBlock($event) : undefined"
@keydown="interactive ? onGalleryBlockKeydown($event, block) : undefined"
@dragleave="onGalleryDragLeaveGallery($event, block)"
>
<figure
v-for="(image, imageIndex) in block.images"
:key="`${block.id}-${imageIndex}-${image.url}`"
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="onGalleryDragOverItem($event, block, imageIndex)"
@drop="onGalleryDrop($event, block, imageIndex)"
<div
v-for="row in getGalleryRows(block.images)"
:key="`${block.id}-row-${row.startIndex}`"
class="content-markdown-renderer__gallery-row flex w-full gap-2"
>
<button
class="content-markdown-renderer__gallery-button w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
type="button"
@click="openLightbox(block.images, imageIndex)"
<figure
v-for="(image, rowImageIndex) in row.images"
:key="`${block.id}-${row.startIndex + rowImageIndex}-${image.url}`"
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 && liveImageDragState?.kind === 'gallery' && liveImageDragState?.blockId === block.id && liveImageDragState?.imageIndex === row.startIndex + rowImageIndex,
'content-markdown-renderer__gallery-item--drop-target': interactive && galleryDropTarget?.blockId === block.id && galleryDropTarget?.targetIndex === row.startIndex + rowImageIndex,
'content-markdown-renderer__gallery-item--add-target': interactive && liveImageDragState?.kind === 'image' && galleryDropTarget?.blockId === block.id && galleryDropTarget?.targetIndex === row.startIndex + rowImageIndex
}"
:style="getGalleryItemStyle(block, image, row.startIndex + rowImageIndex)"
:draggable="interactive"
@dragstart="onGalleryDragStart($event, block, row.startIndex + rowImageIndex)"
@dragend="onGalleryDragEnd"
@dragover="onGalleryDragOverItem($event, block, row.startIndex + rowImageIndex)"
@drop="onGalleryDrop($event, block, row.startIndex + rowImageIndex)"
>
<img
class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]"
:src="image.url"
:alt="getImageAltAttribute(image)"
<div
v-if="interactive"
class="content-markdown-renderer__gallery-item-actions absolute right-2 top-2 z-10 flex gap-1.5"
>
</button>
<figcaption
v-if="getImageDisplayCaption(image)"
class="content-markdown-renderer__gallery-caption mt-1.5 text-center text-xs text-[var(--site-muted)]"
>
{{ getImageDisplayCaption(image) }}
</figcaption>
</figure>
<button
class="content-markdown-renderer__gallery-image-edit content-markdown-renderer__preview-card-action rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white shadow transition-colors hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)]"
type="button"
aria-label="갤러리 이미지 편집"
@click.stop="editGalleryImage(block, row.startIndex + rowImageIndex)"
>
편집
</button>
<button
class="content-markdown-renderer__gallery-image-delete content-markdown-renderer__preview-card-action rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white shadow transition-colors hover:bg-red-600 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)]"
type="button"
aria-label="갤러리 이미지 삭제"
@click.stop="removeGalleryImage(block, row.startIndex + rowImageIndex)"
>
삭제
</button>
</div>
<button
class="content-markdown-renderer__gallery-button w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
type="button"
@click="openLightbox(block.images, row.startIndex + rowImageIndex)"
>
<img
class="content-markdown-renderer__gallery-image h-full w-full object-cover transition-transform hover:scale-[1.02]"
:src="image.url"
:alt="getImageAltAttribute(image)"
@load="onGalleryImageLoad($event, block.id, row.startIndex + rowImageIndex, image.url)"
>
</button>
<figcaption
v-if="getImageDisplayCaption(image)"
class="content-markdown-renderer__gallery-caption mt-1.5 text-center text-xs text-[var(--site-muted)]"
>
{{ getImageDisplayCaption(image) }}
</figcaption>
</figure>
</div>
</div>
<ContentMarkdownCodeBlockEditor
v-else-if="block.type === 'code' && interactive"
@@ -2081,6 +2674,15 @@ onBeforeUnmount(() => {
</p>
</template>
<div
v-if="interactive"
class="content-markdown-renderer__block-insert content-markdown-renderer__block-insert--tail"
:class="{ 'content-markdown-renderer__block-insert--active': blockInsertDropTarget?.insertBeforeLine === tailInsertBeforeLine }"
@dragover="onTailInsertDragOver"
@dragleave="onBlockInsertDragLeave"
@drop="onTailInsertDrop"
/>
<div
v-if="interactive"
class="content-markdown-renderer__live-tail mt-1 min-h-[120px] flex-1 cursor-text"
@@ -2151,7 +2753,7 @@ onBeforeUnmount(() => {
text-align: right;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--site-accent);
color: v-bind(CONTENT_LIST_MARKER_COLOR);
}
.content-markdown-renderer__list-marker--bullet {
@@ -2164,7 +2766,7 @@ onBeforeUnmount(() => {
width: 6px;
height: 6px;
border-radius: 9999px;
background-color: var(--site-accent);
background-color: v-bind(CONTENT_LIST_MARKER_COLOR);
content: '';
}
@@ -2184,6 +2786,29 @@ onBeforeUnmount(() => {
cursor: grab;
}
.content-markdown-renderer__gallery-row {
min-height: clamp(180px, 28vw, 360px);
}
.content-markdown-renderer__gallery-item {
display: flex;
flex-direction: column;
}
.content-markdown-renderer__gallery-item-actions {
opacity: 0;
transition: opacity 0.16s ease;
}
.content-markdown-renderer__gallery-item:hover .content-markdown-renderer__gallery-item-actions,
.content-markdown-renderer__gallery-item:focus-within .content-markdown-renderer__gallery-item-actions {
opacity: 1;
}
.content-markdown-renderer__gallery-button {
flex: 1;
}
.content-markdown-renderer__gallery-item--interactive:active {
cursor: grabbing;
}
@@ -2214,4 +2839,84 @@ onBeforeUnmount(() => {
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}
.content-markdown-renderer__gallery-item--add-target::after {
content: '갤러리에 추가';
}
.content-markdown-renderer__block-insert {
position: relative;
height: 10px;
margin: -2px 0;
z-index: 3;
}
.content-markdown-renderer__block-insert--tail {
margin-top: 4px;
}
.content-markdown-renderer__block-insert--active::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 50%;
z-index: 1;
transform: translateY(-50%);
height: 3px;
border-radius: 9999px;
background: #ff7a00;
box-shadow: 0 0 0 1px rgba(255, 122, 0, 0.35);
pointer-events: none;
}
.content-markdown-renderer__block-insert--active::after {
content: '단일 이미지로 분리';
position: absolute;
left: 50%;
top: 50%;
z-index: 2;
transform: translate(-50%, -50%);
border-radius: 8px;
background: rgba(255, 122, 0, 0.92);
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: -0.02em;
padding: 4px 8px;
pointer-events: none;
white-space: nowrap;
}
.content-markdown-renderer__image-live--dragging {
opacity: 0.5;
cursor: grabbing;
}
.content-markdown-renderer__image-live[draggable='true'] {
cursor: grab;
}
.content-markdown-renderer__image-live--drop-target {
outline: 3px solid #ff7a00;
outline-offset: 4px;
}
.content-markdown-renderer__image-live--drop-target::after {
content: '갤러리로 합치기';
position: absolute;
left: 50%;
top: 50%;
z-index: 5;
transform: translate(-50%, -50%);
border-radius: 8px;
background: rgba(255, 122, 0, 0.92);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: -0.02em;
padding: 6px 10px;
pointer-events: none;
white-space: nowrap;
}
</style>

View File

@@ -9,10 +9,10 @@ defineProps({
<template>
<blockquote
class="prose-blockquote mb-2.5 text-[15px] leading-8 text-[var(--site-text)]"
class="prose-blockquote mb-2.5 text-[15px] leading-8"
:class="variant === 'alt'
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic'
: 'rounded-[10px] border-l-2 border-[#FF1A75] bg-[color-mix(in_srgb,#FF1A75_10%,#ffffff)] px-5 py-4 font-medium'"
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic text-[var(--site-text)]'
: 'rounded-[10px] border-l-2 border-[#FF1A75] bg-[color-mix(in_srgb,#FF1A75_10%,#ffffff)] px-5 py-4 font-medium text-[#15171a]'"
>
<span class="whitespace-pre-line">
<slot />

View File

@@ -1,8 +1,10 @@
<script setup>
defineProps({
import { getImageDefaultAltLabel } from '../../lib/markdown-image.js'
const props = defineProps({
src: {
type: String,
required: true
default: ''
},
alt: {
type: String,
@@ -18,6 +20,42 @@ defineProps({
default: 'regular'
}
})
const loadFailed = ref(false)
const hasRenderableSrc = computed(() => String(props.src || '').trim().length > 0)
const errorLabel = computed(() => {
const trimmed = String(props.src || '').trim()
if (!trimmed) {
return '이미지 URL이 비어 있습니다'
}
const filename = getImageDefaultAltLabel(trimmed)
return filename ? `이미지를 불러올 수 없습니다 · ${filename}` : '이미지를 불러올 수 없습니다'
})
watch(() => props.src, () => {
loadFailed.value = false
})
/**
* 이미지 로드 실패 시 placeholder를 표시한다.
* @returns {void}
*/
const onImageError = () => {
loadFailed.value = true
}
/**
* 이미지 로드 성공 시 오류 상태를 해제한다.
* @returns {void}
*/
const onImageLoad = () => {
loadFailed.value = false
}
</script>
<template>
@@ -28,8 +66,32 @@ defineProps({
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
}"
>
<div class="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
<div
class="prose-image__frame overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
:class="{
'prose-image__frame--empty': !hasRenderableSrc || loadFailed,
'prose-image__frame--broken': loadFailed
}"
>
<img
v-if="hasRenderableSrc && !loadFailed"
class="prose-image__media w-full object-cover"
:src="src"
:alt="alt"
@load="onImageLoad"
@error="onImageError"
>
<div
v-else
class="prose-image__placeholder flex min-h-[180px] flex-col items-center justify-center gap-2 px-4 py-6 text-center"
role="img"
:aria-label="errorLabel"
>
<span class="prose-image__placeholder-icon text-2xl text-[var(--site-muted)]" aria-hidden="true">!</span>
<p class="prose-image__placeholder-text max-w-full break-all text-sm text-[var(--site-muted)]">
{{ errorLabel }}
</p>
</div>
</div>
<figcaption
v-if="caption"
@@ -39,3 +101,26 @@ defineProps({
</figcaption>
</figure>
</template>
<style scoped>
.prose-image__frame--empty,
.prose-image__frame--broken {
min-height: 180px;
}
.prose-image__frame:not(.prose-image__frame--empty):not(.prose-image__frame--broken) {
min-height: 120px;
}
.prose-image__placeholder-icon {
display: inline-flex;
height: 2.5rem;
width: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: 1px dashed var(--site-line);
background: color-mix(in srgb, var(--site-panel) 88%, #fff 12%);
font-weight: 700;
}
</style>