1906 lines
62 KiB
Vue
1906 lines
62 KiB
Vue
<script setup>
|
|
import {
|
|
getImageAltAttribute,
|
|
getImageDisplayCaption
|
|
} from '../../lib/markdown-image.js'
|
|
import {
|
|
getContentDisplayImageSrcset,
|
|
getContentDisplayImageUrl,
|
|
getContentLightboxImageUrl,
|
|
getGalleryImageSizes
|
|
} from '../../lib/content-image-urls.js'
|
|
import {
|
|
appendTextToMarkdownLine,
|
|
getAppendTextForMerge,
|
|
getMergeJunctionDisplayOffset,
|
|
hasListMarker,
|
|
isEmptyListMarkerLine,
|
|
parseOrderedListMarker,
|
|
stripListMarker
|
|
} from '../../lib/markdown-live-edit.js'
|
|
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
|
import {
|
|
buildCalloutOpenerLine,
|
|
CALLOUT_BACKGROUND_OPTIONS
|
|
} from '../../lib/markdown-callout.js'
|
|
import {
|
|
formatQuoteLine,
|
|
getQuoteContentStartLine,
|
|
getQuoteLineEntries,
|
|
normalizeQuoteEditableValue
|
|
} from '../../lib/markdown-quote.js'
|
|
import { getGalleryRows } from '../../lib/content-gallery-images.js'
|
|
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
|
|
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
|
|
import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js'
|
|
import { useContentMarkdownLiveSelection } from '../../composables/useContentMarkdownLiveSelection.js'
|
|
import { useContentMarkdownFencedBlocks } from '../../composables/useContentMarkdownFencedBlocks.js'
|
|
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
|
import ProseCodeBlock from './ProseCodeBlock.vue'
|
|
import ProseHtml from './ProseHtml.vue'
|
|
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
|
|
import ContentMarkdownToggleEditor from './ContentMarkdownToggleEditor.vue'
|
|
|
|
const props = defineProps({
|
|
content: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
/** 관리자 미리보기: 갤러리 드래그 정렬 등 */
|
|
interactive: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** 슬래시 명령 메뉴 키보드 탐색 중 */
|
|
slashMenuActive: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** ESC로 슬래시 메뉴를 닫은 원본 줄 번호(0-based) */
|
|
slashSuppressedLines: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
/** 공개 게시물 본문 중간에 삽입할 인아티클 광고 코드 */
|
|
inArticleAdCode: {
|
|
type: String,
|
|
default: ''
|
|
}
|
|
})
|
|
|
|
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',
|
|
'line-focus',
|
|
'line-blur',
|
|
'slash-update',
|
|
'slash-end',
|
|
'slash-apply',
|
|
'content-replace'
|
|
])
|
|
|
|
/** @type {string} 본문 리스트 마커 색상 */
|
|
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
|
|
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
|
|
const galleryImageAspectRatios = ref({})
|
|
/** @type {number} 문단 분리 연속 호출 방지 */
|
|
let lastParagraphSplitAt = 0
|
|
|
|
const {
|
|
activeLightboxImages,
|
|
activeLightboxImage,
|
|
openContentLightbox: openContentLightboxFromImages,
|
|
closeLightbox,
|
|
showPreviousImage,
|
|
showNextImage
|
|
} = useContentMarkdownLightbox()
|
|
|
|
const {
|
|
blocks,
|
|
normalizedInArticleAdCode,
|
|
headingIdsByBlock,
|
|
renderBlocks,
|
|
tailInsertBeforeLine,
|
|
contentLightboxImages
|
|
} = useContentMarkdownBlocks({
|
|
content: computed(() => props.content),
|
|
interactive: computed(() => props.interactive),
|
|
inArticleAdCode: computed(() => props.inArticleAdCode)
|
|
})
|
|
|
|
const {
|
|
liveImageDragState,
|
|
galleryDropTarget,
|
|
imageBlockDropTarget,
|
|
blockInsertDropTarget,
|
|
getBlockInsertBeforeLine,
|
|
onImageBlockDragStart,
|
|
onImageBlockDragOver,
|
|
onImageBlockDragLeave,
|
|
onImageBlockDrop,
|
|
onImageBlockDragEnd,
|
|
onBlockInsertDragOver,
|
|
onBlockInsertDragLeave,
|
|
onBlockInsertDrop,
|
|
onTailInsertDragOver,
|
|
onTailInsertDrop,
|
|
onGalleryDragStart,
|
|
onGalleryDragEnd,
|
|
onGalleryDragOverItem,
|
|
onGalleryDragLeaveGallery,
|
|
onGalleryDrop
|
|
} = useContentMarkdownImageDrag({
|
|
interactive: computed(() => props.interactive),
|
|
tailInsertBeforeLine,
|
|
onMergeImagesToGallery: (payload) => emit('merge-images-to-gallery', payload),
|
|
onInsertImageToGallery: (payload) => emit('insert-image-to-gallery', payload),
|
|
onExtractGalleryImage: (payload) => emit('extract-gallery-image', payload),
|
|
onGalleryReorder: (payload) => emit('gallery-reorder', payload)
|
|
})
|
|
|
|
const {
|
|
pendingFocusLine,
|
|
pendingFocusPosition,
|
|
pendingFocusOffset,
|
|
rendererRootRef,
|
|
rawEditingSourceLines,
|
|
focusEditableAtLine,
|
|
selectAllLiveDocument,
|
|
onRendererSelectionKeydown,
|
|
emitLiveLineFocus,
|
|
emitLiveLineBlur,
|
|
onInlineRawMode,
|
|
onEditorFocusLine,
|
|
emitEditorFocusedLine
|
|
} = useContentMarkdownLiveSelection({
|
|
content: computed(() => props.content),
|
|
interactive: computed(() => props.interactive),
|
|
onLineFocus: (line) => emit('line-focus', line),
|
|
onLineBlur: () => emit('line-blur'),
|
|
onContentReplace: (payload) => emit('content-replace', payload)
|
|
})
|
|
|
|
const {
|
|
getCodeLineNumbers,
|
|
onCodeBlockCommit,
|
|
onCodeBlockInsertBelow,
|
|
onCalloutBlockCommit,
|
|
onCalloutBlockInsertBelow,
|
|
onToggleBlockCommit,
|
|
onToggleBlockInsertBelow
|
|
} = useContentMarkdownFencedBlocks({
|
|
content: computed(() => props.content),
|
|
interactive: computed(() => props.interactive),
|
|
pendingFocusPosition,
|
|
pendingFocusOffset,
|
|
commitInlineBlockLines: (block, replacementLines) => commitInlineBlockLines(block, replacementLines),
|
|
insertBelowBlock: (block, options) => onInsertBelowBlock(block, options)
|
|
})
|
|
|
|
/**
|
|
* 빈 줄 공백 블록 높이를 반환한다.
|
|
* @param {Object} block - 렌더링 블록
|
|
* @returns {string} Tailwind 높이 클래스
|
|
*/
|
|
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
|
|
|
|
/**
|
|
* 갤러리 이미지 비율 저장 키를 만든다.
|
|
* @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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 갤러리 표시용 이미지 로드 실패 시 원본 이미지로 되돌린다.
|
|
* @param {Event} event - 이미지 로드 이벤트
|
|
* @param {string} fallbackUrl - 원본 이미지 URL
|
|
* @returns {void}
|
|
*/
|
|
const onGalleryImageError = (event, fallbackUrl) => {
|
|
const image = event.target
|
|
|
|
if (!(image instanceof HTMLImageElement) || !fallbackUrl || image.src.endsWith(fallbackUrl)) {
|
|
return
|
|
}
|
|
|
|
image.removeAttribute('srcset')
|
|
image.removeAttribute('sizes')
|
|
image.src = fallbackUrl
|
|
}
|
|
|
|
/**
|
|
* 라이트박스용 파생 이미지 로드 실패 시 원본 이미지로 되돌린다.
|
|
* @param {Event} event - 이미지 로드 이벤트
|
|
* @param {string} fallbackUrl - 원본 이미지 URL
|
|
* @returns {void}
|
|
*/
|
|
const onLightboxImageError = (event, fallbackUrl) => {
|
|
const image = event.target
|
|
|
|
if (!(image instanceof HTMLImageElement) || !fallbackUrl || image.src.endsWith(fallbackUrl)) {
|
|
return
|
|
}
|
|
|
|
image.src = fallbackUrl
|
|
}
|
|
|
|
/**
|
|
* 갤러리 이미지 셀의 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`
|
|
}
|
|
}
|
|
|
|
defineExpose({
|
|
focusEditableAtLine,
|
|
selectAllLiveDocument
|
|
})
|
|
|
|
/**
|
|
* 제목 레벨별 편집 영역 클래스
|
|
* @param {number} level - 제목 레벨
|
|
* @returns {string} 클래스 문자열
|
|
*/
|
|
const getHeadingEditableClass = (level) => {
|
|
const base = 'prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0'
|
|
|
|
if (level === 1) {
|
|
return `${base} text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]`
|
|
}
|
|
|
|
if (level === 2) {
|
|
return `${base} text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]`
|
|
}
|
|
|
|
if (level === 3) {
|
|
return `${base} text-[clamp(1.1rem,1.05rem+0.25vw,1.25rem)]`
|
|
}
|
|
|
|
if (level === 4) {
|
|
return `${base} text-[clamp(1.025rem,1rem+0.2vw,1.15rem)]`
|
|
}
|
|
|
|
if (level === 5) {
|
|
return `${base} text-[clamp(0.95rem,0.925rem+0.15vw,1.05rem)]`
|
|
}
|
|
|
|
return `${base} text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]`
|
|
}
|
|
|
|
/**
|
|
* 표 셀 정렬 클래스를 반환한다.
|
|
* @param {string} alignment - 정렬 값
|
|
* @returns {string} Tailwind 정렬 클래스
|
|
*/
|
|
const getTableCellAlignClass = (alignment) => {
|
|
if (alignment === 'center') {
|
|
return 'text-center'
|
|
}
|
|
|
|
if (alignment === 'right') {
|
|
return 'text-right'
|
|
}
|
|
|
|
return 'text-left'
|
|
}
|
|
|
|
/**
|
|
* 제목 블록 마크다운 줄을 만든다.
|
|
* @param {number} level - 제목 레벨
|
|
* @param {string} value - 제목 텍스트
|
|
* @returns {string} 제목 마크다운 줄
|
|
*/
|
|
const buildHeadingLine = (level, value) => {
|
|
const headingPrefix = `${'#'.repeat(Math.min(Math.max(level, 1), 6))} `
|
|
const cleanText = String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
return `${headingPrefix}${cleanText}`.trimEnd() || headingPrefix.trim()
|
|
}
|
|
|
|
/**
|
|
* commit 이벤트 페이로드를 정규화한다.
|
|
* @param {string|{ value: string, raw?: boolean }} payload - 페이로드
|
|
* @returns {{ value: string, raw: boolean }}
|
|
*/
|
|
const normalizeCommitPayload = (payload) => {
|
|
if (typeof payload === 'string') {
|
|
return { value: payload, raw: false }
|
|
}
|
|
|
|
return {
|
|
value: String(payload?.value ?? ''),
|
|
raw: payload?.raw === true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* insert-below 이벤트 페이로드를 정규화한다.
|
|
* @param {string|Object} payload - 페이로드
|
|
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
|
|
*/
|
|
const normalizeInsertBelowPayload = (payload) => {
|
|
if (typeof payload === 'string') {
|
|
return {
|
|
value: payload,
|
|
raw: false,
|
|
before: '',
|
|
after: '',
|
|
caretAtStart: false,
|
|
caretAtEnd: true
|
|
}
|
|
}
|
|
|
|
return {
|
|
value: String(payload?.value ?? ''),
|
|
raw: payload?.raw === true,
|
|
before: String(payload?.before ?? ''),
|
|
after: String(payload?.after ?? ''),
|
|
caretAtStart: payload?.caretAtStart === true,
|
|
caretAtEnd: payload?.caretAtEnd === true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 원본 마크다운 줄을 반환한다.
|
|
* @param {number} lineIndex - 줄 번호
|
|
* @returns {string} 줄 텍스트
|
|
*/
|
|
const getMarkdownLine = (lineIndex) => String(props.content || '').split('\n')[lineIndex] ?? ''
|
|
|
|
/**
|
|
* 블록에 해당하는 원본 마크다운 줄 목록을 반환한다.
|
|
* @param {Object} block - 블록
|
|
* @returns {string[]} 줄 목록
|
|
*/
|
|
const getBlockSourceLines = (block) => String(props.content || '').split('\n').slice(
|
|
block.meta.startLine,
|
|
(block.meta.endLine ?? block.meta.startLine) + 1
|
|
)
|
|
|
|
/**
|
|
* 목록 항목 마크다운 줄을 만든다.
|
|
* @param {string} value - 편집 값
|
|
* @param {boolean} raw - 원문 모드 여부
|
|
* @param {Object} block - 목록 블록
|
|
* @param {number} itemIndex - 항목 인덱스
|
|
* @returns {string} 마크다운 줄
|
|
*/
|
|
/**
|
|
* 순서 목록 항목에 표시할 번호를 반환한다.
|
|
* @param {Object} block - 목록 블록
|
|
* @param {number} itemIndex - 항목 인덱스
|
|
* @returns {number} 목록 번호
|
|
*/
|
|
const getListMarkerNumber = (block, itemIndex) => {
|
|
if (block.listNumbers?.[itemIndex] != null) {
|
|
return block.listNumbers[itemIndex]
|
|
}
|
|
|
|
const line = getMarkdownLine(block.meta.startLine + itemIndex)
|
|
const parsed = parseOrderedListMarker(line)
|
|
|
|
return parsed?.number ?? itemIndex + 1
|
|
}
|
|
|
|
const formatListLine = (value, raw, block, itemIndex) => {
|
|
if (raw) {
|
|
if (!hasListMarker(value, block.ordered)) {
|
|
return String(value ?? '').trim()
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
const clean = stripListMarker(value, block.ordered)
|
|
|
|
if (block.ordered) {
|
|
const number = getListMarkerNumber(block, itemIndex)
|
|
return clean ? `${number}. ${clean}` : `${number}. `
|
|
}
|
|
|
|
return clean ? `- ${clean}` : '- '
|
|
}
|
|
|
|
/**
|
|
* 빈 목록 마커 줄을 만든다.
|
|
* @param {Object} block - 목록 블록
|
|
* @param {number} itemIndex - 항목 인덱스
|
|
* @returns {string} 마크다운 줄
|
|
*/
|
|
const createEmptyListMarkerLine = (block, itemIndex) => {
|
|
if (block.ordered) {
|
|
return `${getListMarkerNumber(block, itemIndex)}. `
|
|
}
|
|
|
|
return '- '
|
|
}
|
|
|
|
/**
|
|
* 인라인 편집 결과를 마크다운 줄로 반영한다.
|
|
* @param {Object} block - 블록
|
|
* @param {string[]} replacementLines - 대체 줄
|
|
* @returns {void}
|
|
*/
|
|
const commitInlineBlockLines = (block, replacementLines) => {
|
|
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
|
|
return
|
|
}
|
|
|
|
emit('block-content-change', {
|
|
startLine: block.meta.startLine,
|
|
endLine: block.meta.endLine ?? block.meta.startLine,
|
|
replacementLines
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 블록 위에 기본 문단을 삽입한다.
|
|
* @param {Object} block - 기준 블록
|
|
* @returns {void}
|
|
*/
|
|
const onInsertAboveBlock = (block) => {
|
|
const startLine = block.meta.startLine
|
|
|
|
pendingFocusLine.value = startLine
|
|
pendingFocusPosition.value = 'start'
|
|
emit('insert-after-line', {
|
|
afterLine: startLine - 1,
|
|
lines: [''],
|
|
focusLine: startLine
|
|
})
|
|
}
|
|
|
|
const onParagraphInlineCommit = (block, text) => {
|
|
const value = String(text ?? '').replace(/\r/g, '')
|
|
const lines = value.split('\n').map((line) => line.trimEnd())
|
|
|
|
if (lines.length <= 1) {
|
|
commitInlineBlockLines(block, [lines[0] ?? ''])
|
|
return
|
|
}
|
|
|
|
commitInlineBlockLines(block, lines.filter((line) => line.length > 0))
|
|
}
|
|
|
|
/**
|
|
* 빈 줄(스페이서) 편집 반영
|
|
* @param {Object} block - 블록
|
|
* @param {string} text - 편집된 텍스트
|
|
* @returns {void}
|
|
*/
|
|
const onSpacerInlineCommit = (block, text) => {
|
|
if (!String(text ?? '').trim()) {
|
|
return
|
|
}
|
|
|
|
commitInlineBlockLines(block, [text])
|
|
}
|
|
|
|
/**
|
|
* 문단 입력 중 블록 단축 변환을 처리한다.
|
|
* @param {Object} block - 문단 블록
|
|
* @param {string} text - 입력 텍스트
|
|
* @returns {void}
|
|
*/
|
|
const onParagraphLiveInput = (block, text) => {
|
|
if (String(text ?? '').trim() !== '>') {
|
|
return
|
|
}
|
|
|
|
pendingFocusLine.value = block.meta.startLine
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, ['> '])
|
|
}
|
|
|
|
/**
|
|
* 문단 Enter 분리 결과 줄 배열을 만든다.
|
|
* @param {string} head - 커서 앞 텍스트
|
|
* @param {string} tail - 커서 뒤 텍스트
|
|
* @returns {string[]} 분리된 줄
|
|
*/
|
|
const buildParagraphSplitLines = (head, tail) => {
|
|
const h = String(head ?? '').replace(/\n/g, ' ')
|
|
const t = String(tail ?? '').replace(/\n/g, ' ')
|
|
|
|
if (!t.length) {
|
|
return [h, '']
|
|
}
|
|
|
|
if (!h.length) {
|
|
return ['', t]
|
|
}
|
|
|
|
return [h, t]
|
|
}
|
|
|
|
/**
|
|
* 문단 Enter 입력을 블록 단축 입력으로 변환할지 확인한다.
|
|
* @param {Object} block - 문단 블록
|
|
* @param {string} before - 커서 앞 텍스트
|
|
* @param {string} after - 커서 뒤 텍스트
|
|
* @returns {boolean} 변환 처리 여부
|
|
*/
|
|
const applyParagraphShortcutSplit = (block, before, after) => {
|
|
const head = String(before ?? '').trim()
|
|
const tail = String(after ?? '')
|
|
|
|
if (tail.trim()) {
|
|
return false
|
|
}
|
|
|
|
if (/^```[A-Za-z0-9_-]*$/.test(head)) {
|
|
const opener = head === '```' ? buildCodeFenceOpener({ language: '', showLineNumbers: false }) : head
|
|
|
|
pendingFocusLine.value = block.meta.startLine + 1
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, [opener, '', '```'])
|
|
return true
|
|
}
|
|
|
|
if (head === '!!!' || head === ':::callout') {
|
|
pendingFocusLine.value = block.meta.startLine + 1
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, [
|
|
buildCalloutOpenerLine({
|
|
calloutEmojiEnabled: false,
|
|
calloutEmoji: '💡',
|
|
calloutBackground: 'blue',
|
|
title: ''
|
|
}),
|
|
'',
|
|
'',
|
|
':::'
|
|
])
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* 문단 Enter 분리 — 마크다운에 빈 줄을 넣어 다음 문단을 만든다.
|
|
* @param {Object} block - 블록
|
|
* @param {{ before: string, after: string }} payload - 커서 앞·뒤 텍스트
|
|
* @returns {void}
|
|
*/
|
|
const onParagraphSplit = (block, { before, after }) => {
|
|
const now = Date.now()
|
|
|
|
if (now - lastParagraphSplitAt < 120) {
|
|
return
|
|
}
|
|
|
|
lastParagraphSplitAt = now
|
|
|
|
if (applyParagraphShortcutSplit(block, before, after)) {
|
|
return
|
|
}
|
|
|
|
const replacementLines = buildParagraphSplitLines(before, after)
|
|
const focusLine = block.meta.startLine + Math.max(replacementLines.length - 1, 1)
|
|
|
|
pendingFocusLine.value = focusLine
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, replacementLines)
|
|
}
|
|
|
|
/**
|
|
* 블록 아래에 줄을 삽입한다.
|
|
* @param {Object} block - 블록
|
|
* @param {{ lines?: string[] }} options - 삽입 옵션
|
|
* @returns {void}
|
|
*/
|
|
const onInsertBelowBlock = (block, options = {}) => {
|
|
const endLine = block.meta.endLine ?? block.meta.startLine
|
|
const lines = options.lines ?? ['']
|
|
|
|
pendingFocusLine.value = endLine + 1
|
|
emit('insert-after-line', {
|
|
afterLine: endLine,
|
|
lines,
|
|
focusLine: endLine + 1
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 선택형 프리뷰 카드 블록에 포커스를 준다.
|
|
* @param {Event} event - 포커스 유도 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const focusPreviewCardBlock = (event) => {
|
|
const target = event.currentTarget
|
|
const element = target instanceof HTMLElement
|
|
? target.closest('[data-preview-card-block="true"]') || target
|
|
: null
|
|
|
|
if (element instanceof HTMLElement) {
|
|
element.focus({ preventScroll: true })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 선택형 프리뷰 카드에서 이전/다음 줄로 이동한다.
|
|
* @param {Object} block - 프리뷰 카드 블록
|
|
* @param {1|-1} direction - 이동 방향
|
|
* @returns {void}
|
|
*/
|
|
const moveFromPreviewCardBlock = (block, direction) => {
|
|
const startLine = block.meta?.startLine
|
|
const endLine = block.meta?.endLine ?? startLine
|
|
|
|
if (typeof startLine !== 'number' || typeof endLine !== 'number') {
|
|
return
|
|
}
|
|
|
|
if (direction > 0) {
|
|
const lines = String(props.content || '').split('\n')
|
|
const nextLine = endLine + 1
|
|
|
|
if (nextLine >= lines.length) {
|
|
onInsertBelowBlock(block)
|
|
return
|
|
}
|
|
|
|
focusEditableAtLine(nextLine, 0, 'start')
|
|
return
|
|
}
|
|
|
|
if (startLine <= 0) {
|
|
return
|
|
}
|
|
|
|
focusEditableAtLine(startLine - 1, 0, 'end')
|
|
}
|
|
|
|
/**
|
|
* 선택형 프리뷰 카드 블록 전체를 삭제한다.
|
|
* @param {Object} block - 프리뷰 카드 블록
|
|
* @returns {void}
|
|
*/
|
|
const deletePreviewCardBlock = (block) => {
|
|
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
|
|
return
|
|
}
|
|
|
|
const startLine = block.meta.startLine
|
|
const endLine = block.meta.endLine ?? startLine
|
|
pendingFocusLine.value = startLine > 0 ? startLine - 1 : 0
|
|
pendingFocusPosition.value = startLine > 0 ? 'end' : 'start'
|
|
emit('block-content-change', {
|
|
startLine,
|
|
endLine,
|
|
replacementLines: []
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 라이브 갤러리에서 특정 이미지만 삭제한다.
|
|
* @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.shiftKey && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
|
return
|
|
}
|
|
|
|
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 - 키보드 이벤트
|
|
* @param {Object} block - 프리뷰 카드 블록
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewCardKeydown = (event, block) => {
|
|
const isDeleteShortcut = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k'
|
|
const isActionButton = event.target instanceof HTMLElement
|
|
&& Boolean(event.target.closest('.content-markdown-renderer__preview-card-action'))
|
|
|
|
if (isActionButton && (event.key === 'Enter' || event.key === ' ')) {
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Backspace' || event.key === 'Delete' || isDeleteShortcut) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
deletePreviewCardBlock(block)
|
|
return
|
|
}
|
|
|
|
if (!event.shiftKey && event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
moveFromPreviewCardBlock(block, 1)
|
|
return
|
|
}
|
|
|
|
if (!event.shiftKey && event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
moveFromPreviewCardBlock(block, -1)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
onInsertBelowBlock(block)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 목록 항목 Enter — 빈 마커 줄이면 문단으로 탈출, 내용이 있으면 아래에 빈 줄만 삽입한다.
|
|
* @param {Object} block - 목록 블록
|
|
* @param {number} itemIndex - 항목 인덱스
|
|
* @param {string|{ value: string, raw?: boolean }} payload - 편집 내용
|
|
* @returns {void}
|
|
*/
|
|
const onListItemInsertBelow = (block, itemIndex, payload) => {
|
|
const { value, raw, before, after, caretAtStart, caretAtEnd } = normalizeInsertBelowPayload(payload)
|
|
const nextLines = getBlockSourceLines(block)
|
|
|
|
if (caretAtStart && after.length) {
|
|
nextLines[itemIndex] = formatListLine(after, raw, block, itemIndex)
|
|
nextLines.splice(itemIndex, 0, createEmptyListMarkerLine(block, itemIndex))
|
|
pendingFocusLine.value = block.meta.startLine + itemIndex
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, nextLines)
|
|
return
|
|
}
|
|
|
|
if (before.length && after.length) {
|
|
nextLines[itemIndex] = formatListLine(before, raw, block, itemIndex)
|
|
nextLines.splice(itemIndex + 1, 0, formatListLine(after, raw, block, itemIndex + 1))
|
|
pendingFocusLine.value = block.meta.startLine + itemIndex + 1
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, nextLines)
|
|
return
|
|
}
|
|
|
|
if (itemIndex < nextLines.length) {
|
|
nextLines[itemIndex] = formatListLine(value, raw, block, itemIndex)
|
|
}
|
|
|
|
const committedLine = nextLines[itemIndex] ?? ''
|
|
|
|
if (isEmptyListMarkerLine(committedLine, block.ordered)) {
|
|
nextLines[itemIndex] = ''
|
|
pendingFocusLine.value = block.meta.startLine + itemIndex
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, nextLines)
|
|
return
|
|
}
|
|
|
|
commitInlineBlockLines(block, nextLines)
|
|
|
|
if (caretAtEnd || !before.length) {
|
|
pendingFocusLine.value = (block.meta.endLine ?? block.meta.startLine) + 1
|
|
pendingFocusPosition.value = 'start'
|
|
onInsertBelowBlock(block, { lines: [''] })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드 하단 클릭 — 새 문단 추가
|
|
* @returns {void}
|
|
*/
|
|
const onLiveTailClick = () => {
|
|
if (!props.interactive) {
|
|
return
|
|
}
|
|
|
|
const lines = String(props.content || '').split('\n')
|
|
|
|
if (!lines.length || lines[lines.length - 1] !== '') {
|
|
emit('append-paragraph')
|
|
pendingFocusLine.value = lines.length ? lines.length + 1 : 0
|
|
return
|
|
}
|
|
|
|
pendingFocusLine.value = lines.length - 1
|
|
nextTick(() => {
|
|
focusEditableAtLine(lines.length - 1)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 제목 인라인 편집 반영
|
|
* @param {Object} block - 블록
|
|
* @param {string} text - 편집된 텍스트
|
|
* @returns {void}
|
|
*/
|
|
const onHeadingInlineCommit = (block, payload) => {
|
|
const { value, raw } = normalizeCommitPayload(payload)
|
|
|
|
if (raw) {
|
|
commitInlineBlockLines(block, [value])
|
|
return
|
|
}
|
|
|
|
commitInlineBlockLines(block, [buildHeadingLine(block.level, value)])
|
|
}
|
|
|
|
/**
|
|
* 제목 Enter — 현재 제목 값을 저장하고 아래에 빈 줄을 만든다.
|
|
* @param {Object} block - 제목 블록
|
|
* @param {string|Object} payload - insert-below 페이로드
|
|
* @returns {void}
|
|
*/
|
|
const onHeadingInsertBelow = (block, payload) => {
|
|
const { value } = normalizeInsertBelowPayload(payload)
|
|
pendingFocusLine.value = block.meta.startLine + 1
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, [buildHeadingLine(block.level, value), ''])
|
|
}
|
|
|
|
/**
|
|
* 인용 줄 인라인 편집 반영
|
|
* @param {Object} block - 인용 블록
|
|
* @param {number} lineIndex - 줄 인덱스
|
|
* @param {string|{ value: string, raw?: boolean }} payload - 편집 내용
|
|
* @returns {void}
|
|
*/
|
|
const onQuoteLineInlineCommit = (block, lineIndex, payload) => {
|
|
const { value, raw } = normalizeCommitPayload(payload)
|
|
const sourceLines = String(props.content || '').split('\n').slice(
|
|
block.meta.startLine,
|
|
(block.meta.endLine ?? block.meta.startLine) + 1
|
|
)
|
|
const nextLines = [...sourceLines]
|
|
|
|
nextLines[lineIndex] = formatQuoteLine(value, raw)
|
|
commitInlineBlockLines(block, nextLines)
|
|
}
|
|
|
|
/**
|
|
* 인용 줄 아래에 새 인용 줄을 삽입한다.
|
|
* @param {Object} block - 인용 블록
|
|
* @param {number} lineIndex - 줄 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const onQuoteLineInsertBelow = (block, lineIndex, payload) => {
|
|
const { value, raw, before, after, caretAtStart } = normalizeInsertBelowPayload(payload)
|
|
const nextLines = getBlockSourceLines(block)
|
|
|
|
if (caretAtStart && after.length) {
|
|
nextLines[lineIndex] = formatQuoteLine(after, raw)
|
|
nextLines.splice(lineIndex, 0, '> ')
|
|
pendingFocusLine.value = block.meta.startLine + lineIndex
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, nextLines)
|
|
return
|
|
}
|
|
|
|
if (before.length && after.length) {
|
|
nextLines[lineIndex] = formatQuoteLine(before, raw)
|
|
nextLines.splice(lineIndex + 1, 0, formatQuoteLine(after, raw))
|
|
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, nextLines)
|
|
return
|
|
}
|
|
|
|
if (lineIndex < nextLines.length) {
|
|
nextLines[lineIndex] = formatQuoteLine(value, raw)
|
|
}
|
|
|
|
nextLines.splice(lineIndex + 1, 0, '> ')
|
|
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, nextLines)
|
|
}
|
|
|
|
/**
|
|
* 인용 블록 원본에서 옵션 선언 줄을 반환한다.
|
|
* @param {Object} block - 인용 블록
|
|
* @returns {string[]} 옵션 선언 줄
|
|
*/
|
|
const getQuoteOptionLines = (block) => {
|
|
const contentStartLine = getQuoteContentStartLine(block)
|
|
|
|
if (contentStartLine <= block.meta.startLine) {
|
|
return []
|
|
}
|
|
|
|
return getBlockSourceLines(block).slice(0, contentStartLine - block.meta.startLine)
|
|
}
|
|
|
|
/**
|
|
* 인용 본문 문자열을 마크다운 줄로 변환한다.
|
|
* @param {Object} block - 인용 블록
|
|
* @param {string|{ value?: string }} payload - 편집 페이로드
|
|
* @returns {string[]} 인용 마크다운 줄
|
|
*/
|
|
const buildQuoteBlockLines = (block, payload) => {
|
|
const value = typeof payload === 'string'
|
|
? payload
|
|
: String(payload?.value ?? '')
|
|
const bodyLines = String(value ?? '').replace(/\r/g, '').split('\n')
|
|
const normalizedBodyLines = bodyLines.length ? bodyLines : ['']
|
|
|
|
return [
|
|
...getQuoteOptionLines(block),
|
|
...normalizedBodyLines.map((line) => formatQuoteLine(line, false))
|
|
]
|
|
}
|
|
|
|
/**
|
|
* 인용 블록 편집 반영
|
|
* @param {Object} block - 인용 블록
|
|
* @param {string|{ value?: string }} payload - 편집 페이로드
|
|
* @returns {void}
|
|
*/
|
|
const onQuoteBlockCommit = (block, payload) => {
|
|
const normalizedPayload = typeof payload === 'object' && payload !== null
|
|
? { ...payload, value: normalizeQuoteEditableValue(payload.value) }
|
|
: normalizeQuoteEditableValue(payload)
|
|
|
|
commitInlineBlockLines(block, buildQuoteBlockLines(block, normalizedPayload))
|
|
}
|
|
|
|
/**
|
|
* 인용 블록 마지막 줄에서 아래로 이탈한다.
|
|
* @param {Object} block - 인용 블록
|
|
* @param {string|Object} payload - insert-below 페이로드
|
|
* @returns {void}
|
|
*/
|
|
const onQuoteBlockInsertBelow = (block, payload) => {
|
|
const { value } = normalizeInsertBelowPayload(payload)
|
|
|
|
onQuoteBlockCommit(block, value)
|
|
pendingFocusPosition.value = 'start'
|
|
pendingFocusOffset.value = 0
|
|
onInsertBelowBlock(block, { lines: [''] })
|
|
}
|
|
|
|
/**
|
|
* 인용 블록을 일반 문단 줄로 되돌린다.
|
|
* @param {Object} block - 인용 블록
|
|
* @param {string|Object} payload - 편집 페이로드
|
|
* @returns {void}
|
|
*/
|
|
const onQuoteBlockConvertToParagraph = (block, payload) => {
|
|
const { value } = normalizeCommitPayload(payload)
|
|
const lines = normalizeQuoteEditableValue(value).split('\n')
|
|
const replacementLines = lines.length ? lines : ['']
|
|
|
|
pendingFocusLine.value = block.meta.startLine
|
|
pendingFocusPosition.value = 'start'
|
|
commitInlineBlockLines(block, replacementLines)
|
|
}
|
|
|
|
/**
|
|
* 목록 항목 인라인 편집 반영
|
|
* @param {Object} block - 블록
|
|
* @param {number} itemIndex - 항목 인덱스
|
|
* @param {string|{ value: string, raw?: boolean }} payload - 편집 내용
|
|
* @returns {void}
|
|
*/
|
|
const onListItemInlineCommit = (block, itemIndex, payload) => {
|
|
const { value, raw } = normalizeCommitPayload(payload)
|
|
const sourceLines = String(props.content || '').split('\n').slice(
|
|
block.meta.startLine,
|
|
(block.meta.endLine ?? block.meta.startLine) + 1
|
|
)
|
|
|
|
const nextLines = sourceLines.map((line, index) => {
|
|
if (index !== itemIndex) {
|
|
return line
|
|
}
|
|
|
|
return formatListLine(value, raw, block, index)
|
|
})
|
|
|
|
commitInlineBlockLines(block, nextLines)
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드에서 현재 줄을 삭제한다.
|
|
* @param {number} lineIndex - 줄 번호
|
|
* @returns {void}
|
|
*/
|
|
const onDeleteLine = (lineIndex) => {
|
|
if (typeof lineIndex !== 'number' || lineIndex < 0) {
|
|
return
|
|
}
|
|
|
|
const fencedBlock = blocks.value.find((block) => {
|
|
const startLine = block.meta?.startLine
|
|
const endLine = block.meta?.endLine
|
|
|
|
return ['callout', 'code', 'toggle'].includes(block.type)
|
|
&& typeof startLine === 'number'
|
|
&& typeof endLine === 'number'
|
|
&& lineIndex > startLine
|
|
&& lineIndex < endLine
|
|
})
|
|
|
|
if (fencedBlock) {
|
|
const startLine = fencedBlock.meta.startLine
|
|
const endLine = fencedBlock.meta.endLine
|
|
const bodyLineCount = Math.max(0, endLine - startLine - 1)
|
|
|
|
if (bodyLineCount <= 1) {
|
|
pendingFocusLine.value = startLine > 0 ? startLine - 1 : 0
|
|
pendingFocusPosition.value = startLine > 0 ? 'end' : 'start'
|
|
emit('block-content-change', {
|
|
startLine,
|
|
endLine,
|
|
replacementLines: []
|
|
})
|
|
return
|
|
}
|
|
|
|
pendingFocusLine.value = lineIndex > startLine + 1 ? lineIndex - 1 : lineIndex
|
|
pendingFocusPosition.value = lineIndex > startLine + 1 ? 'end' : 'start'
|
|
emit('delete-line', lineIndex)
|
|
return
|
|
}
|
|
|
|
const focusLine = lineIndex > 0 ? lineIndex - 1 : 0
|
|
|
|
pendingFocusLine.value = focusLine
|
|
pendingFocusPosition.value = lineIndex > 0 ? 'end' : 'start'
|
|
emit('delete-line', lineIndex)
|
|
}
|
|
|
|
/**
|
|
* 현재 줄을 이전 줄 끝에 병합한다.
|
|
* @param {number} lineIndex - 줄 번호
|
|
* @param {string|Object} payload - 편집 내용
|
|
* @returns {void}
|
|
*/
|
|
const onMergeWithPreviousLine = (lineIndex, payload) => {
|
|
if (typeof lineIndex !== 'number' || lineIndex <= 0) {
|
|
return
|
|
}
|
|
|
|
const { value, raw } = normalizeCommitPayload(payload)
|
|
const lines = String(props.content || '').split('\n')
|
|
const prevLine = lines[lineIndex - 1] ?? ''
|
|
const appendText = getAppendTextForMerge(value, prevLine, raw)
|
|
|
|
if (!appendText) {
|
|
onDeleteLine(lineIndex)
|
|
return
|
|
}
|
|
|
|
const mergedLine = appendTextToMarkdownLine(prevLine, appendText)
|
|
|
|
pendingFocusLine.value = lineIndex - 1
|
|
pendingFocusPosition.value = 'auto'
|
|
pendingFocusOffset.value = getMergeJunctionDisplayOffset(prevLine, raw)
|
|
emit('merge-with-previous-line', { lineIndex, mergedLine })
|
|
}
|
|
|
|
/**
|
|
* 본문 전체 이미지 목록에서 지정 이미지를 라이트박스로 연다.
|
|
* @param {string} lightboxKey - 이미지 식별 키
|
|
* @returns {void}
|
|
*/
|
|
const openContentLightbox = (lightboxKey) => {
|
|
openContentLightboxFromImages(contentLightboxImages.value, lightboxKey)
|
|
}
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="rendererRootRef"
|
|
class="content-markdown-renderer"
|
|
@mousedown.capture="emitLiveLineFocus"
|
|
@focusin.capture="emitLiveLineFocus"
|
|
@focusout.capture="emitLiveLineBlur"
|
|
@keydown.capture="onRendererSelectionKeydown"
|
|
>
|
|
<ContentMarkdownBlockGroup
|
|
v-for="block in renderBlocks"
|
|
:key="block.id"
|
|
:interactive="interactive"
|
|
:show-insert="getBlockInsertBeforeLine(block) !== null"
|
|
: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"
|
|
block-class="content-markdown-renderer__paragraph content-markdown-renderer__spacer-line text-base text-[var(--site-text)]"
|
|
enter-mode="split-paragraph"
|
|
:slash-menu-active="slashMenuActive"
|
|
:slash-command-suppressed="slashSuppressedLines.includes(block.meta.startLine)"
|
|
:source-line="block.meta.startLine"
|
|
:model-value="''"
|
|
@input="onParagraphLiveInput(block, $event)"
|
|
@commit="onSpacerInlineCommit(block, $event)"
|
|
@split="onParagraphSplit(block, $event)"
|
|
@delete-line="onDeleteLine"
|
|
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
|
@slash-update="emit('slash-update', $event)"
|
|
@slash-end="emit('slash-end', $event)"
|
|
@slash-apply="emit('slash-apply', $event)"
|
|
/>
|
|
<div v-else-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
|
|
<ContentMarkdownEditableInline
|
|
v-else-if="block.type === 'heading' && interactive"
|
|
:tag="`h${Math.min(Math.max(block.level, 1), 6)}`"
|
|
:block-class="getHeadingEditableClass(block.level)"
|
|
enter-mode="insert-below"
|
|
:source-line="block.meta.startLine"
|
|
:model-value="block.text"
|
|
@commit="onHeadingInlineCommit(block, $event)"
|
|
@insert-below="onHeadingInsertBelow(block, $event)"
|
|
@delete-line="onDeleteLine"
|
|
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
|
/>
|
|
<ProseHeading v-else-if="block.type === 'heading'" :id="headingIdsByBlock[block.id]" :level="block.level">
|
|
<ContentMarkdownInlineSegments :text="block.text" />
|
|
</ProseHeading>
|
|
<ProseBlockquote
|
|
v-else-if="block.type === 'quote' && interactive && block.variant !== 'alt'"
|
|
:variant="block.variant || 'default'"
|
|
:background="block.quoteBackground"
|
|
:data-source-line="block.meta.startLine"
|
|
>
|
|
<ContentMarkdownEditableInline
|
|
block-class="content-markdown-renderer__quote-line"
|
|
:model-value="block.text"
|
|
enter-mode="multiline"
|
|
block-markdown
|
|
arrow-exit-creates-line
|
|
preserve-empty-line-on-full-delete
|
|
empty-markdown-line="> "
|
|
:source-line="getQuoteContentStartLine(block)"
|
|
:source-line-count="getQuoteLineEntries(block).length"
|
|
@input="onQuoteBlockCommit(block, $event)"
|
|
@commit="onQuoteBlockCommit(block, $event)"
|
|
@insert-above="onInsertAboveBlock(block)"
|
|
@insert-below="onQuoteBlockInsertBelow(block, $event)"
|
|
@delete-line="onDeleteLine"
|
|
@merge-with-previous="onQuoteBlockConvertToParagraph(block, $event)"
|
|
@focus-line="emitEditorFocusedLine"
|
|
/>
|
|
</ProseBlockquote>
|
|
<ProseBlockquote
|
|
v-else-if="block.type === 'quote'"
|
|
:variant="block.variant || 'default'"
|
|
:background="block.quoteBackground"
|
|
>
|
|
<ContentMarkdownInlineSegments :text="block.text" />
|
|
</ProseBlockquote>
|
|
<ProseList v-else-if="block.type === 'list' && interactive" :ordered="block.ordered || false">
|
|
<li
|
|
v-for="(item, itemIndex) in block.text"
|
|
:key="`list-line-${block.meta.startLine + itemIndex}`"
|
|
class="content-markdown-renderer__list-item flex items-start gap-2"
|
|
:class="rawEditingSourceLines.has(block.meta.startLine + itemIndex) ? 'content-markdown-renderer__list-item--raw' : ''"
|
|
>
|
|
<span
|
|
v-if="block.ordered"
|
|
class="content-markdown-renderer__list-marker content-markdown-renderer__list-marker--ordered"
|
|
aria-hidden="true"
|
|
>{{ getListMarkerNumber(block, itemIndex) }}.</span>
|
|
<span
|
|
v-else
|
|
class="content-markdown-renderer__list-marker content-markdown-renderer__list-marker--bullet"
|
|
aria-hidden="true"
|
|
/>
|
|
<ContentMarkdownEditableInline
|
|
class="min-w-0 flex-1"
|
|
:model-value="item"
|
|
enter-mode="insert-below"
|
|
allow-raw-toggle
|
|
:raw-line="getMarkdownLine(block.meta.startLine + itemIndex)"
|
|
:source-line="block.meta.startLine + itemIndex"
|
|
@commit="onListItemInlineCommit(block, itemIndex, $event)"
|
|
@insert-below="onListItemInsertBelow(block, itemIndex, $event)"
|
|
@delete-line="onDeleteLine"
|
|
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + itemIndex, $event)"
|
|
@raw-mode="onInlineRawMode"
|
|
/>
|
|
</li>
|
|
</ProseList>
|
|
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
|
|
<li
|
|
v-for="(item, itemIndex) in block.text"
|
|
:key="`${block.id}-${itemIndex}`"
|
|
class="content-markdown-renderer__list-item flex items-start gap-2"
|
|
>
|
|
<span
|
|
v-if="block.ordered"
|
|
class="content-markdown-renderer__list-marker content-markdown-renderer__list-marker--ordered"
|
|
aria-hidden="true"
|
|
>{{ getListMarkerNumber(block, itemIndex) }}.</span>
|
|
<span
|
|
v-else
|
|
class="content-markdown-renderer__list-marker content-markdown-renderer__list-marker--bullet"
|
|
aria-hidden="true"
|
|
/>
|
|
<span class="min-w-0 flex-1">
|
|
<ContentMarkdownInlineSegments :text="item" />
|
|
</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-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="getContentDisplayImageUrl(block.url)"
|
|
:srcset="getContentDisplayImageSrcset(block.url)"
|
|
:fallback-src="block.url"
|
|
:alt="getImageAltAttribute(block)"
|
|
:caption="getImageDisplayCaption(block)"
|
|
:variant="block.width"
|
|
:pixel-width="block.pixelWidth"
|
|
expandable
|
|
@activate="openContentLightbox(block.id)"
|
|
/>
|
|
</section>
|
|
<ProseImage
|
|
v-else-if="block.type === 'image'"
|
|
:src="getContentDisplayImageUrl(block.url)"
|
|
:srcset="getContentDisplayImageSrcset(block.url)"
|
|
:fallback-src="block.url"
|
|
:alt="getImageAltAttribute(block)"
|
|
:caption="getImageDisplayCaption(block)"
|
|
:variant="block.width"
|
|
:pixel-width="block.pixelWidth"
|
|
expandable
|
|
@activate="openContentLightbox(block.id)"
|
|
/>
|
|
<ContentMarkdownCalloutEditor
|
|
v-else-if="block.type === 'callout' && interactive"
|
|
:callout-emoji-enabled="block.calloutEmojiEnabled"
|
|
:callout-emoji="block.calloutEmoji"
|
|
:callout-title="block.title"
|
|
:callout-background="block.calloutBackground"
|
|
:block-source-line="block.meta.startLine"
|
|
:body-source-line="block.meta.startLine + 1"
|
|
:model-value="block.text"
|
|
@commit="onCalloutBlockCommit(block, $event)"
|
|
@delete-line="onDeleteLine"
|
|
@insert-above="onInsertAboveBlock(block)"
|
|
@insert-below="onCalloutBlockInsertBelow(block, $event)"
|
|
@merge-with-previous="onMergeWithPreviousLine"
|
|
@focus-line="emitEditorFocusedLine"
|
|
/>
|
|
<ProseCallout
|
|
v-else-if="block.type === 'callout'"
|
|
:emoji-enabled="block.calloutEmojiEnabled"
|
|
:emoji="block.calloutEmoji"
|
|
:title="block.title"
|
|
:background="block.calloutBackground"
|
|
>
|
|
<ContentMarkdownRenderer :content="block.text" />
|
|
</ProseCallout>
|
|
<ContentMarkdownToggleEditor
|
|
v-else-if="block.type === 'toggle' && interactive"
|
|
:title="block.title"
|
|
:default-open="block.defaultOpen"
|
|
:title-source-line="block.meta.startLine"
|
|
:body-source-line="block.meta.startLine + 1"
|
|
:model-value="block.text"
|
|
@commit="onToggleBlockCommit(block, $event)"
|
|
@insert-above="onInsertAboveBlock(block)"
|
|
@insert-below="onToggleBlockInsertBelow(block, $event)"
|
|
@delete-line="onDeleteLine"
|
|
/>
|
|
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'" :default-open="block.defaultOpen" animated>
|
|
<ContentMarkdownRenderer :content="block.text" />
|
|
</ProseToggle>
|
|
<ProseBookmark
|
|
v-else-if="block.type === 'bookmark' && block.meta.url"
|
|
:url="block.meta.url"
|
|
:title="block.meta.title"
|
|
:description="block.meta.description"
|
|
:thumbnail="block.meta.thumbnail"
|
|
/>
|
|
<ProseSignup
|
|
v-else-if="block.type === 'signup'"
|
|
:title="block.meta.title"
|
|
:description="block.meta.description"
|
|
:button-label="block.meta.button"
|
|
:placeholder="block.meta.placeholder"
|
|
/>
|
|
<section
|
|
v-else-if="['video', 'audio', 'file'].includes(block.type) && interactive"
|
|
class="content-markdown-renderer__preview-card group relative my-8 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="true"
|
|
tabindex="0"
|
|
role="group"
|
|
:aria-label="`${block.type} 블록`"
|
|
@mousedown.capture="focusPreviewCardBlock"
|
|
@keydown="onPreviewCardKeydown($event, block)"
|
|
>
|
|
<button
|
|
class="content-markdown-renderer__preview-card-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
|
|
type="button"
|
|
:aria-label="`${block.type} 삭제`"
|
|
@click.stop="deletePreviewCardBlock(block)"
|
|
>
|
|
삭제
|
|
</button>
|
|
<ProseVideo
|
|
v-if="block.type === 'video'"
|
|
:src="block.meta.url"
|
|
:title="block.meta.title"
|
|
:poster="block.meta.poster"
|
|
:caption="block.meta.caption"
|
|
/>
|
|
<ProseAudio
|
|
v-else-if="block.type === 'audio'"
|
|
:src="block.meta.url"
|
|
:title="block.meta.title"
|
|
:description="block.meta.description"
|
|
/>
|
|
<ProseFile
|
|
v-else
|
|
:href="block.meta.url"
|
|
:title="block.meta.title"
|
|
:description="block.meta.description"
|
|
:file-name="block.meta.fileName"
|
|
:size="block.meta.size"
|
|
/>
|
|
</section>
|
|
<ProseVideo
|
|
v-else-if="block.type === 'video'"
|
|
:src="block.meta.url"
|
|
:title="block.meta.title"
|
|
:poster="block.meta.poster"
|
|
:caption="block.meta.caption"
|
|
/>
|
|
<ProseAudio
|
|
v-else-if="block.type === 'audio'"
|
|
:src="block.meta.url"
|
|
:title="block.meta.title"
|
|
:description="block.meta.description"
|
|
/>
|
|
<ProseFile
|
|
v-else-if="block.type === 'file'"
|
|
:href="block.meta.url"
|
|
:title="block.meta.title"
|
|
:description="block.meta.description"
|
|
:file-name="block.meta.fileName"
|
|
:size="block.meta.size"
|
|
/>
|
|
<section
|
|
v-else-if="['link', 'iframe', 'html'].includes(block.type) && interactive"
|
|
class="content-markdown-renderer__embed-live group relative mb-8 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="true"
|
|
tabindex="0"
|
|
role="group"
|
|
:aria-label="block.type === 'link' ? '링크 블록' : block.type === 'iframe' ? 'iframe 블록' : 'HTML 블록'"
|
|
@mousedown.capture="focusPreviewCardBlock"
|
|
@keydown="onPreviewCardKeydown($event, block)"
|
|
>
|
|
<button
|
|
class="content-markdown-renderer__embed-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
|
|
type="button"
|
|
:aria-label="block.type === 'link' ? '링크 블록 삭제' : block.type === 'iframe' ? 'iframe 블록 삭제' : 'HTML 블록 삭제'"
|
|
@click.stop="deletePreviewCardBlock(block)"
|
|
>
|
|
삭제
|
|
</button>
|
|
<ProseHtml v-if="block.type === 'html'" :html="block.url" />
|
|
<ProseEmbed v-else :url="block.url" />
|
|
</section>
|
|
<ProseHtml v-else-if="block.type === 'html'" :html="block.url" />
|
|
<ProseEmbed v-else-if="block.type === 'link' || block.type === 'iframe'" :url="block.url" />
|
|
<div
|
|
v-else-if="block.type === 'gallery'"
|
|
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)"
|
|
>
|
|
<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"
|
|
>
|
|
<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)"
|
|
>
|
|
<div
|
|
v-if="interactive"
|
|
class="content-markdown-renderer__gallery-item-actions absolute right-2 top-2 z-10 flex gap-1.5"
|
|
>
|
|
<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"
|
|
aria-label="이미지 크게 보기"
|
|
@click="openContentLightbox(`${block.id}:${row.startIndex + rowImageIndex}`)"
|
|
>
|
|
<img
|
|
class="content-markdown-renderer__gallery-image h-full w-full object-cover transition-transform hover:scale-[1.02]"
|
|
:src="getContentDisplayImageUrl(image.url)"
|
|
:srcset="getContentDisplayImageSrcset(image.url) || undefined"
|
|
:sizes="getContentDisplayImageSrcset(image.url) ? getGalleryImageSizes(row.images.length) : undefined"
|
|
:alt="getImageAltAttribute(image)"
|
|
loading="lazy"
|
|
decoding="async"
|
|
@load="onGalleryImageLoad($event, block.id, row.startIndex + rowImageIndex, image.url)"
|
|
@error="onGalleryImageError($event, 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>
|
|
<div
|
|
v-else-if="block.type === 'table'"
|
|
class="content-markdown-renderer__table-wrap my-6 overflow-x-auto"
|
|
:data-source-line="block.meta.startLine"
|
|
:data-source-line-end="block.meta.endLine"
|
|
>
|
|
<table class="content-markdown-renderer__table w-full min-w-[520px] border-collapse text-sm text-[var(--site-text)]">
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
v-for="(header, headerIndex) in block.meta.headers"
|
|
:key="`${block.id}-table-head-${headerIndex}`"
|
|
class="border border-line bg-[color-mix(in_srgb,var(--site-text)_6%,transparent)] px-3 py-2 font-semibold"
|
|
:class="getTableCellAlignClass(block.meta.alignments?.[headerIndex])"
|
|
>
|
|
<ContentMarkdownInlineSegments :text="header" multiline />
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, rowIndex) in block.meta.rows" :key="`${block.id}-table-row-${rowIndex}`">
|
|
<td
|
|
v-for="(_, cellIndex) in block.meta.headers"
|
|
:key="`${block.id}-table-cell-${rowIndex}-${cellIndex}`"
|
|
class="border border-line px-3 py-2 align-top"
|
|
:class="getTableCellAlignClass(block.meta.alignments?.[cellIndex])"
|
|
>
|
|
<ContentMarkdownInlineSegments :text="row[cellIndex] || ''" multiline />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<ContentMarkdownCodeBlockEditor
|
|
v-else-if="block.type === 'code' && interactive"
|
|
:language="block.codeLanguage"
|
|
:show-line-numbers="block.codeShowLineNumbers"
|
|
:body-source-line="block.meta.startLine + 1"
|
|
:model-value="block.text"
|
|
@commit="onCodeBlockCommit(block, $event)"
|
|
@insert-above="onInsertAboveBlock(block)"
|
|
@insert-below="onCodeBlockInsertBelow(block, $event)"
|
|
@delete-line="onDeleteLine"
|
|
@focus-line="emitEditorFocusedLine"
|
|
/>
|
|
<ProseCodeBlock
|
|
v-else-if="block.type === 'code'"
|
|
class="content-markdown-renderer__code"
|
|
:language="block.codeLanguage"
|
|
:show-line-numbers="block.codeShowLineNumbers"
|
|
:line-numbers="getCodeLineNumbers(block)"
|
|
show-copy
|
|
:copy-text="block.text"
|
|
>
|
|
<code>{{ block.text }}</code>
|
|
</ProseCodeBlock>
|
|
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-5 border-line">
|
|
<ContentMarkdownEditableInline
|
|
v-else-if="block.type === 'paragraph' && interactive"
|
|
tag="p"
|
|
block-class="content-markdown-renderer__paragraph content-markdown-renderer__paragraph--editable mb-2.5 min-h-[1.75rem] text-base text-[var(--site-text)] last:mb-0"
|
|
enter-mode="split-paragraph"
|
|
:slash-menu-active="slashMenuActive"
|
|
:slash-command-suppressed="slashSuppressedLines.includes(block.meta.startLine)"
|
|
:source-line="block.meta.startLine"
|
|
:model-value="block.text"
|
|
@input="onParagraphLiveInput(block, $event)"
|
|
@commit="onParagraphInlineCommit(block, $event)"
|
|
@split="onParagraphSplit(block, $event)"
|
|
@delete-line="onDeleteLine"
|
|
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
|
@slash-update="emit('slash-update', $event)"
|
|
@slash-end="emit('slash-end', $event)"
|
|
@slash-apply="emit('slash-apply', $event)"
|
|
/>
|
|
<p v-else-if="block.type === 'paragraph'" class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
|
|
<ContentMarkdownInlineSegments :text="block.text" multiline />
|
|
</p>
|
|
<SiteAdSlot
|
|
v-else-if="block.type === 'in-article-ad'"
|
|
class="content-markdown-renderer__in-article-ad my-8"
|
|
:code="normalizedInArticleAdCode"
|
|
location="post-in-article"
|
|
/>
|
|
</ContentMarkdownBlockGroup>
|
|
|
|
<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"
|
|
role="button"
|
|
tabindex="-1"
|
|
aria-label="새 문단 추가"
|
|
@mousedown.prevent="onLiveTailClick"
|
|
/>
|
|
|
|
<div
|
|
v-if="activeLightboxImage"
|
|
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="갤러리 이미지 보기"
|
|
tabindex="-1"
|
|
@click.self="closeLightbox"
|
|
>
|
|
<button class="content-markdown-renderer__lightbox-close absolute right-5 top-5 rounded bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeLightbox">
|
|
닫기
|
|
</button>
|
|
<button
|
|
v-if="activeLightboxImages.length > 1"
|
|
class="content-markdown-renderer__lightbox-prev absolute left-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
|
type="button"
|
|
@click="showPreviousImage"
|
|
>
|
|
이전
|
|
</button>
|
|
<img
|
|
class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain"
|
|
:src="getContentLightboxImageUrl(activeLightboxImage.url)"
|
|
:alt="getImageAltAttribute(activeLightboxImage)"
|
|
@error="onLightboxImageError($event, activeLightboxImage.url)"
|
|
>
|
|
<button
|
|
v-if="activeLightboxImages.length > 1"
|
|
class="content-markdown-renderer__lightbox-next absolute right-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
|
type="button"
|
|
@click="showNextImage"
|
|
>
|
|
다음
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.content-markdown-renderer__spacer-line {
|
|
margin-bottom: 0;
|
|
min-height: 1.75rem;
|
|
padding: 0;
|
|
}
|
|
|
|
.content-markdown-renderer__quote-line {
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.content-markdown-renderer__quote-line:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.content-markdown-renderer__list-marker {
|
|
width: 21px;
|
|
height: 2rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.content-markdown-renderer__list-marker--ordered {
|
|
text-align: right;
|
|
font-weight: 500;
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 2rem;
|
|
color: v-bind(CONTENT_LIST_MARKER_COLOR);
|
|
}
|
|
|
|
.content-markdown-renderer__list-marker--bullet {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.content-markdown-renderer__list-marker--bullet::after {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 9999px;
|
|
background-color: v-bind(CONTENT_LIST_MARKER_COLOR);
|
|
content: '';
|
|
}
|
|
|
|
.content-markdown-renderer__list-item--raw .content-markdown-renderer__list-marker {
|
|
opacity: 0.35;
|
|
}
|
|
|
|
.content-markdown-renderer__paragraph--editable:empty {
|
|
min-height: 1.75rem;
|
|
}
|
|
|
|
.content-markdown-renderer {
|
|
user-select: text;
|
|
}
|
|
|
|
.content-markdown-renderer__gallery-item--interactive {
|
|
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;
|
|
}
|
|
|
|
.content-markdown-renderer__gallery-item--dragging {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.content-markdown-renderer__gallery-item--drop-target {
|
|
outline: 3px solid #ff7a00;
|
|
outline-offset: 3px;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.content-markdown-renderer__gallery-item--drop-target::after {
|
|
content: '여기로 이동';
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 2;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 10px;
|
|
background: rgba(255, 122, 0, 0.22);
|
|
color: #fff;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
pointer-events: none;
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
|
}
|
|
|
|
.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>
|