v1.4.2: 라이브 이미지·갤러리 편집 UX와 공개 화면 색상 정리
라이브 모드 이미지·갤러리 드래그 병합·분리, 갤러리 개별 편집, 블록 패널 유지, 다크모드 인용·사이드바·리스트 마커 색상을 보정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user