v1.4.2: 라이브 이미지·갤러리 편집 UX와 공개 화면 색상 정리
라이브 모드 이미지·갤러리 드래그 병합·분리, 갤러리 개별 편집, 블록 패널 유지, 다크모드 인용·사이드바·리스트 마커 색상을 보정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -176,7 +176,7 @@
|
||||
|
||||
.site-sidebar {
|
||||
min-height: 0;
|
||||
background: var(--site-panel);
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.4.2
|
||||
|
||||
- 글쓰기 소스 모드에서 긴 줄이 자동 줄바꿈될 때 라인 번호가 실제 줄 높이와 어긋나던 문제를 수정했다.
|
||||
- 소스 모드에서 라이브 모드로 전환한 직후에도 현재 줄에 포커스가 유지되도록 보정했다.
|
||||
- 라이브 모드에서 소스 모드로 돌아올 때 현재 작성 위치와 가까운 줄로 커서·스크롤을 복원하도록 보정했다.
|
||||
- 이미지 파일 URL 한 줄을 입력했을 때 임베드가 아니라 이미지로 표시되도록 수정했다.
|
||||
- 라이브 모드 이미지 블록에 편집·삭제 버튼을 추가하고, 편집 버튼을 기존 이미지 설정 패널과 연결했다.
|
||||
- 잘못된 이미지 URL·로드 실패 시에도 최소 높이와 오류 안내 placeholder를 표시한다.
|
||||
- 라이브 모드에서 이미지 블록끼리 드래그해 갤러리로 합치고, 갤러리 이미지를 블록 사이에 드롭해 단일 이미지로 분리할 수 있다.
|
||||
- 단독 이미지 URL 줄도 드래그 갤러리 병합·추가 대상으로 처리한다.
|
||||
- 라이브 모드에서 단일 이미지를 기존 갤러리에 드래그로 추가할 수 있다.
|
||||
- 갤러리는 이미지 수와 실제 비율에 따라 행 너비를 자동 조정한다.
|
||||
- 라이브 모드 갤러리 블록도 키보드 이동과 편집·삭제 버튼 접근을 지원한다.
|
||||
- 라이브 모드 갤러리에서는 개별 이미지별 편집·삭제 버튼을 제공한다.
|
||||
- 갤러리 이미지 추가 모달을 열어도 오른쪽 블록 패널 상태가 유지되며, 패널 바깥 클릭 시 닫힌다.
|
||||
- 다크모드 기본 인용 블록과 공개 본문 리스트 마커 색상을 글쓰기 화면 기준으로 정리했다.
|
||||
- 다크모드에서 좌우 사이드바 배경이 본문 배경과 다르게 튀어 보이지 않도록 통일했다.
|
||||
|
||||
## v1.4.1
|
||||
|
||||
- 관리자에서 비디오 등 대용량 미디어 업로드 시 적용되던 10MB 공통 한도를 종류별로 분리했다(비디오 기본 200MB).
|
||||
|
||||
13
docs/map.md
13
docs/map.md
@@ -28,6 +28,7 @@
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
| lib/markdown-block-context.js | 관리자 Markdown textarea 커서 위치 기준 이미지·갤러리·임베드 블록 설정 패널 대상 판별 |
|
||||
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
|
||||
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 |
|
||||
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
|
||||
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
|
||||
@@ -77,8 +78,8 @@
|
||||
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드) |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||
@@ -95,11 +96,11 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
| components/content/ProseBlockquote.vue | 인용구 |
|
||||
| components/content/ProseBlockquote.vue | 인용구, 다크모드 기본 인용 텍스트 가독성 보정 |
|
||||
| components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 줄번호 gutter, 공개 복사 버튼) |
|
||||
| components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) |
|
||||
| components/content/ProseButton.vue | 버튼 |
|
||||
@@ -289,7 +290,7 @@
|
||||
| package.json | Nuxt 실행 스크립트와 의존성 |
|
||||
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
|
||||
| tailwind.config.js | Tailwind 테마 설정 |
|
||||
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
|
||||
| assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
|
||||
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |
|
||||
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
|
||||
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
||||
|
||||
26
docs/spec.md
26
docs/spec.md
@@ -46,7 +46,7 @@
|
||||
- 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리
|
||||
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고 이미지 URL(`SITE_BRAND_LOGO_URL`, localStorage) 또는 사이트 제목(`NUXT_PUBLIC_SITE_TITLE`)을 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다. `site_settings.logo_text`(기본 `井`)는 **이미지 로고가 없을 때** 헤더·사이드바에 쓰는 짧은 기호이며 localStorage·스플래시와는 별개다.
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 라인으로 구분한다. 사이드바 자체 배경은 라이트/다크 모두 기본 화면 배경(`--site-bg`)과 통일하고, 내부 카드형 요소만 패널 배경을 사용한다.
|
||||
|
||||
### 홈 Featured (인덱스)
|
||||
|
||||
@@ -170,11 +170,11 @@ components/content/
|
||||
- 리스트
|
||||
- Unordered: `- 항목`
|
||||
- Ordered: `1. 항목`
|
||||
- 렌더링: `ProseList.vue` (마커 컬러, 간격, 줄높이 통일)
|
||||
- 렌더링: `ProseList.vue` (마커 컬러는 글쓰기 화면과 같은 파란 계열, 간격, 줄높이 통일)
|
||||
- 인용구
|
||||
- 기본: `> 한 줄` 또는 `>` 연속 여러 줄(멀티라인)
|
||||
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
|
||||
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`)
|
||||
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`, 기본 인용은 다크모드에서도 밝은 배경 위 어두운 텍스트 유지)
|
||||
- 이미지
|
||||
- 기본: `` — 이미지 아래 캡션 없음
|
||||
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
|
||||
@@ -183,7 +183,8 @@ components/content/
|
||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
||||
- 이미지 갤러리
|
||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스, Esc 닫기·←/→ 이전·다음)
|
||||
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 라이트박스, Esc 닫기·←/→ 이전·다음)
|
||||
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
|
||||
- 비디오·오디오·파일 카드
|
||||
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)
|
||||
- 오디오: `:::audio` ~ `:::` (`url`, `title`, `description`)
|
||||
@@ -520,12 +521,17 @@ components/content/
|
||||
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
|
||||
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
||||
- 관리자 미리보기에서 `ContentMarkdownRenderer`에 `interactive`를 켠다. 갤러리 이미지는 드래그로 순서를 바꿀 수 있으며, 드래그 중 다른 셀 위에 올리면 해당 셀에 주황 테두리와 「여기로 이동」 표시로 드롭 위치를 보여 준 뒤 `gallery-reorder`로 마크다운을 갱신한다.
|
||||
- 라이브 모드 단일 이미지 블록은 드래그 가능하다. `` 이미지 줄과 단독 이미지 URL 줄 모두 같은 이미지 블록으로 다룬다. 다른 이미지 블록 위에 드롭하면 두 줄을 `:::gallery` fenced block 한 개로 병합하며(`merge-images-to-gallery`), 문서 순서를 유지해 위쪽 이미지가 먼저 들어간다. 자동 인접 병합은 하지 않는다.
|
||||
- 라이브 모드 갤러리 블록은 이미지 블록과 같은 선택형 카드로 취급한다. Tab/클릭으로 포커스할 수 있고, 포커스 상태에서 방향키 위/아래 이동을 지원한다. 갤러리 이미지 hover/focus 시 개별 편집/삭제 버튼을 제공하며, 편집 버튼은 해당 이미지 줄 기준으로 갤러리 블록 설정 패널을 연다.
|
||||
- 라이브 모드 단일 이미지 블록을 기존 갤러리 이미지 셀에 드롭하면 해당 셀 뒤에 이미지를 추가하고 원래 단일 이미지 줄은 제거한다(`insert-image-to-gallery`).
|
||||
- 라이브 모드 갤러리 이미지를 블록 사이 얇은 삽입선(또는 문서 맨 아래 삽입선)에 드롭하면 해당 위치에 단일 이미지 마크다운 줄을 삽입하고 갤러리에서 제거한다(`extract-gallery-image`). 갤러리에 이미지가 1장만 남으면 갤러리 블록을 단일 이미지 줄로 바꾸고, 0장이면 갤러리 블록을 제거한다.
|
||||
- `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다.
|
||||
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). 콜아웃 옵션은 첫 줄 `:::callout emoji=💡 bg=blue`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple|pink)로 지정하며, 라이브 모드에서는 아이콘 클릭으로 모달에서 편집한다(이모지 7종 프리셋·배경색 스와치, 직접 입력 없음). 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `` 형식으로 삽입한다.
|
||||
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
||||
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄 또는 기존 `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문 포커스가 완전히 이탈하면 슬라이드 아웃한다.
|
||||
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄 또는 기존 `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다.
|
||||
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL). `AdminMarkdownEditor`는 `block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
|
||||
- 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 **주황(`#ff7a00`) 굵은 테두리**로 표시한다.
|
||||
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
||||
@@ -571,8 +577,9 @@ components/content/
|
||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 캡션 토글 시 `` 형식으로 저장한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 캡션 토글 시 `` 형식으로 저장한다. 단독 이미지 파일 URL(`jpg`, `png`, `webp`, `gif`, `avif`, `svg`) 한 줄은 임베드가 아니라 이미지 블록으로 렌더링한다.
|
||||
- 이미지/갤러리 삽입 시 캡션은 기본 비우며, 블록 설정 패널에서 **파일명을 캡션으로 사용** 토글로 이미지 아래에 URL 파일명을 표시한다.
|
||||
- 라이브 모드 이미지 블록은 hover/focus 시 우측 상단에 `편집`·`삭제` 버튼을 표시한다. `편집`은 기존 오른쪽 이미지 설정 패널을 열어 이미지 URL·캡션·파일명 캡션 사용 여부를 수정한다.
|
||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||
- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
|
||||
@@ -586,11 +593,14 @@ components/content/
|
||||
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
|
||||
- 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다.
|
||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다.
|
||||
- 임베드 블록은 단독 `http(s)` URL 한 줄을 기본 저장 형식으로 사용한다.
|
||||
- 임베드 블록은 이미지 파일 URL을 제외한 단독 `http(s)` URL 한 줄을 기본 저장 형식으로 사용한다.
|
||||
- 기존 `:::embed` fenced block은 이전 콘텐츠 호환을 위해 계속 파싱·렌더링한다.
|
||||
- 관리자 Markdown-first 에디터의 라이브/스타일 모드에서 임베드 블록은 URL 입력 카드 없이 즉시 실제 임베드 프리뷰로 표시된다. 임베드·비디오·오디오·파일 프리뷰 카드는 hover/focus 시 우측 상단 삭제 버튼을 표시한다. 블록 래퍼에 포커스한 상태에서 `Backspace`·`Delete`·`Ctrl/Cmd+Shift+K`로 삭제하고, `Enter`로 아래 빈 줄을 추가하며, `ArrowUp`·`ArrowDown`은 브라우저 스크롤 대신 이전/다음 편집 줄로 이동한다.
|
||||
- 라이브/스타일 모드에서 제목 블록 Enter는 현재 제목 내용을 저장한 뒤 바로 아래 빈 문단을 추가하고, 원문 마크다운 편집 상태로 전환하지 않는다.
|
||||
- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다. 라이브 모드 전환 시 미리보기 스크롤은 맨 위에서 시작한다.
|
||||
- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다.
|
||||
- 소스 모드 라인 번호는 논리 줄 수를 표시하되, 긴 문장 자동 줄바꿈으로 textarea의 한 줄 높이가 늘어나면 라인 번호 칸도 같은 높이로 맞춘다.
|
||||
- 소스 모드에서 라이브 모드로 전환하면 현재 textarea 커서 줄과 줄 안 오프셋을 기준으로 대응하는 라이브 편집 블록에 포커스를 둔다. 이때 현재 화면 위치를 불필요하게 맨 위나 맨 아래로 이동하지 않는다.
|
||||
- 라이브 모드에서 소스 모드로 전환하면 현재 포커스된 블록 또는 화면 상단에 가까운 원본 줄을 기준으로 textarea 커서와 스크롤 위치를 복원한다.
|
||||
- YouTube 임베드 URL은 공개 화면에서 본문 폭 기준 16:9 iframe으로 렌더링한다.
|
||||
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다. X 공식 iframe의 내부 최대 폭 때문에 공개 화면에서는 카드 폭을 좁혀 중앙 정렬한다.
|
||||
- Mastodon 공개 게시물 URL(`/@user/id`, `/users/user/statuses/id`)은 `{원본 URL}/embed` iframe으로 렌더링한다. iframe 로드 후 Mastodon 공식 embed 방식과 같은 `postMessage` 높이 요청을 보내 응답 높이를 반영한다. 인스턴스가 embed를 차단하거나 지원하지 않으면 브라우저 iframe 정책에 따라 표시되지 않을 수 있다.
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.4.2
|
||||
|
||||
- 관리자 글쓰기: 소스 모드 긴 문장 자동 줄바꿈 시 라인 번호 높이를 실제 wrap 높이에 맞춰 보정.
|
||||
- 관리자 글쓰기: 라이브 모드에서 소스 모드로 전환할 때 현재 포커스·화면 위치에 가까운 원본 줄로 커서와 스크롤 복원.
|
||||
- 관리자 글쓰기: 소스 모드에서 라이브 모드로 전환할 때 현재 textarea 커서 줄을 라이브 편집 블록에 포커스하도록 보정.
|
||||
- 콘텐츠 렌더러: 단독 이미지 파일 URL은 임베드가 아니라 이미지 블록으로 렌더링하도록 보정.
|
||||
- 관리자 글쓰기: 라이브 모드 이미지 블록 hover/focus 시 편집·삭제 버튼 표시, 편집 버튼으로 기존 오른쪽 이미지 설정 패널 연결.
|
||||
- `ProseImage`: URL 비어 있음·로드 실패 시 최소 높이 placeholder와 오류 안내 표시.
|
||||
- 관리자 라이브 미리보기: 이미지 블록을 다른 이미지 블록에 드롭하면 `:::gallery`로 병합(`merge-images-to-gallery`).
|
||||
- 관리자 라이브 미리보기: 갤러리 이미지를 블록 사이 삽입선에 드롭하면 단일 이미지 블록으로 분리(`extract-gallery-image`), 갤러리 1장 남으면 단일 이미지로 정리·0장이면 제거.
|
||||
- 관리자 라이브 미리보기: 단독 이미지 URL 줄도 이미지 드래그 병합·갤러리 추가 대상으로 인식하도록 보정.
|
||||
- 관리자 라이브 미리보기: 단일 이미지 블록을 기존 갤러리 셀에 드롭해 갤러리에 추가(`insert-image-to-gallery`).
|
||||
- 콘텐츠 렌더러: 갤러리를 최대 3개 단위 행으로 나누고 이미지 자연 비율에 따라 셀 너비를 조정.
|
||||
- 관리자 라이브 미리보기: 갤러리 블록도 포커스·방향키 이동·편집/삭제 버튼 접근이 가능하도록 보정.
|
||||
- 관리자 라이브 미리보기: 갤러리 전체 편집/삭제 버튼을 개별 이미지 편집/삭제 버튼으로 변경.
|
||||
- 관리자 글쓰기: 블록 설정 패널 바깥 클릭 시 닫힘, 갤러리 이미지 추가 미디어 모달 중 패널 유지.
|
||||
- 콘텐츠 렌더러: 다크모드 기본 인용 블록 텍스트를 어두운 색으로 고정해 가독성 보정.
|
||||
- 콘텐츠 렌더러: 공개 본문 리스트 번호·점 색상을 글쓰기 화면과 같은 파란 계열로 통일.
|
||||
- 공개 레이아웃: 다크모드에서 좌우 사이드바 배경을 홈페이지 기본 배경(`--site-bg`)과 동일하게 통일.
|
||||
|
||||
## v1.4.1
|
||||
|
||||
- 관리자 미디어 업로드: 이미지·비디오·오디오·문서별 최대 크기 한도 분리(`MAX_VIDEO_FILE_SIZE` 등). 기본 비디오 200MB.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseImageMarkdownLine } from './markdown-image.js'
|
||||
import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js'
|
||||
|
||||
/**
|
||||
* fenced 블록 시작 줄 인덱스를 찾는다.
|
||||
@@ -44,6 +44,13 @@ const findFencedBlockEnd = (lines, startLine) => {
|
||||
*/
|
||||
const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim())
|
||||
|
||||
/**
|
||||
* 단독 이미지 URL 줄인지 확인한다.
|
||||
* @param {string} line - 마크다운 줄
|
||||
* @returns {boolean} 이미지 URL 여부
|
||||
*/
|
||||
const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageUrl(line)
|
||||
|
||||
/**
|
||||
* 갤러리 fenced 블록을 파싱한다.
|
||||
* @param {string[]} lines - 본문 줄 목록
|
||||
@@ -67,6 +74,9 @@ const resolveGalleryBlock = (lines, currentLine) => {
|
||||
kind: 'gallery',
|
||||
startLine: galleryStart,
|
||||
endLine: galleryEnd,
|
||||
selectedImageIndex: currentLine > galleryStart && currentLine < galleryEnd
|
||||
? currentLine - galleryStart - 1
|
||||
: null,
|
||||
images: lines
|
||||
.slice(galleryStart + 1, galleryEnd)
|
||||
.map(parseImageMarkdownLine)
|
||||
@@ -83,7 +93,7 @@ const resolveGalleryBlock = (lines, currentLine) => {
|
||||
const resolveEmbedBlock = (lines, currentLine) => {
|
||||
const standaloneUrl = String(lines[currentLine] || '').trim()
|
||||
|
||||
if (isStandaloneUrlLine(standaloneUrl)) {
|
||||
if (isStandaloneUrlLine(standaloneUrl) && !isStandaloneImageUrlLine(standaloneUrl)) {
|
||||
return {
|
||||
kind: 'embed',
|
||||
startLine: currentLine,
|
||||
@@ -122,6 +132,7 @@ export const resolveActiveBlockContext = (markdown, lineIndex) => {
|
||||
const lines = String(markdown || '').split('\n')
|
||||
const currentLine = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1))
|
||||
const activeImage = parseImageMarkdownLine(lines[currentLine] || '')
|
||||
const activeImageUrl = String(lines[currentLine] || '').trim()
|
||||
|
||||
if (activeImage) {
|
||||
return {
|
||||
@@ -132,6 +143,15 @@ export const resolveActiveBlockContext = (markdown, lineIndex) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isStandaloneImageUrlLine(activeImageUrl)) {
|
||||
return {
|
||||
kind: 'image',
|
||||
startLine: currentLine,
|
||||
endLine: currentLine,
|
||||
images: [{ url: activeImageUrl, width: 'regular', caption: '', useAlt: false }]
|
||||
}
|
||||
}
|
||||
|
||||
const gallery = resolveGalleryBlock(lines, currentLine)
|
||||
|
||||
if (gallery) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** @type {RegExp} 이미지 마크다운 한 줄 */
|
||||
const IMAGE_MARKDOWN_LINE_RE = /^!\[(.*?)\]\((\S+?)(?:\s+"((?:[^"\\]|\\.)*)")?\)(?:\{width=(regular|wide|full)\})?$/
|
||||
/** @type {RegExp} 이미지 파일 확장자 */
|
||||
const IMAGE_URL_EXTENSION_RE = /\.(?:jpe?g|png|webp|gif|avif|svg)(?:$|[?#])/i
|
||||
|
||||
/**
|
||||
* 캡션 문자열 이스케이프 해제
|
||||
@@ -55,6 +57,25 @@ export const getImageDefaultAltLabel = (url) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일 URL인지 확인한다.
|
||||
* @param {string} url - 검사할 URL
|
||||
* @returns {boolean} 이미지 URL 여부
|
||||
*/
|
||||
export const isImageUrl = (url) => {
|
||||
const raw = String(url || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return IMAGE_URL_EXTENSION_RE.test(new URL(raw, 'https://sori.studio').pathname)
|
||||
} catch {
|
||||
return IMAGE_URL_EXTENSION_RE.test(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 한 줄 파싱
|
||||
* @param {string} line - 마크다운 줄
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user