3587 lines
112 KiB
Vue
3587 lines
112 KiB
Vue
<script setup>
|
|
import {
|
|
getImageAltAttribute,
|
|
getImageDisplayCaption,
|
|
isImageUrl,
|
|
parseImageMarkdownLine
|
|
} from '../../lib/markdown-image.js'
|
|
import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
|
|
import {
|
|
applyLiveSelectionDelete,
|
|
collapseLiveSelection,
|
|
extendSelectionAcrossBlocks,
|
|
getSelectableEditableElements,
|
|
isLiveSelectionDeleteKey,
|
|
LIVE_SELECTION_BRIDGE_KEY,
|
|
selectAllEditableElements
|
|
} from '../../lib/markdown-live-selection.js'
|
|
import {
|
|
appendTextToMarkdownLine,
|
|
getAppendTextForMerge,
|
|
getMergeJunctionDisplayOffset,
|
|
hasListMarker,
|
|
hasQuoteMarker,
|
|
isEmptyListMarkerLine,
|
|
parseOrderedListMarker,
|
|
stripListMarker,
|
|
stripQuoteMarker
|
|
} from '../../lib/markdown-live-edit.js'
|
|
import { buildCodeBlockLines, buildCodeFenceOpener, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
|
|
import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js'
|
|
import {
|
|
buildCalloutOpenerLine,
|
|
CALLOUT_BACKGROUND_OPTIONS,
|
|
QUOTE_BACKGROUND_OPTIONS,
|
|
parseCalloutOptions
|
|
} from '../../lib/markdown-callout.js'
|
|
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
|
|
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
|
import ProseCodeBlock from './ProseCodeBlock.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',
|
|
'edit-image',
|
|
'line-focus',
|
|
'line-blur',
|
|
'slash-update',
|
|
'slash-end',
|
|
'slash-apply',
|
|
'content-replace'
|
|
])
|
|
|
|
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<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'>} 포커스 후 커서 위치 */
|
|
const pendingFocusPosition = ref('auto')
|
|
/** @type {import('vue').Ref<number|null>} 포커스 후 텍스트 오프셋 */
|
|
const pendingFocusOffset = ref(null)
|
|
const rendererRootRef = ref(null)
|
|
/** @type {import('vue').Ref<Set<number>>} 원문(raw) 편집 중인 목록 줄 */
|
|
const rawEditingSourceLines = ref(new Set())
|
|
/** @type {number} 문단 분리 연속 호출 방지 */
|
|
let lastParagraphSplitAt = 0
|
|
|
|
/**
|
|
* 마크다운 블록을 생성
|
|
* @param {string} type - 블록 타입
|
|
* @param {string|Array<string>} text - 블록 텍스트
|
|
* @param {number|null} level - 제목 레벨
|
|
* @param {string} id - 블록 ID
|
|
* @param {Object} options - 추가 블록 옵션
|
|
* @returns {Object} 블록
|
|
*/
|
|
/**
|
|
* 블록에 원본 마크다운 줄 범위를 붙인다.
|
|
* @param {Object} block - 블록
|
|
* @param {number} startLine - 시작 줄(0-based)
|
|
* @param {number} endLine - 끝 줄(0-based)
|
|
* @returns {Object} 블록
|
|
*/
|
|
const attachSourceRange = (block, startLine, endLine) => {
|
|
block.meta = {
|
|
...(block.meta && typeof block.meta === 'object' ? block.meta : {}),
|
|
startLine,
|
|
endLine: endLine ?? startLine
|
|
}
|
|
|
|
return block
|
|
}
|
|
|
|
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
|
id,
|
|
type,
|
|
text,
|
|
level,
|
|
url: options.url || '',
|
|
alt: options.alt || '',
|
|
caption: options.caption || '',
|
|
useAlt: options.useAlt === true,
|
|
title: options.title || '',
|
|
variant: options.variant || '',
|
|
ordered: options.ordered || false,
|
|
listNumbers: Array.isArray(options.listNumbers) ? options.listNumbers : [],
|
|
width: options.width || 'regular',
|
|
images: options.images || [],
|
|
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
|
|
calloutEmojiEnabled: options.calloutEmojiEnabled === true,
|
|
calloutEmoji: options.calloutEmoji || '💡',
|
|
calloutBackground: options.calloutBackground || 'blue',
|
|
quoteBackground: options.quoteBackground || 'gray',
|
|
codeLanguage: options.codeLanguage || '',
|
|
codeShowLineNumbers: options.codeShowLineNumbers !== false
|
|
})
|
|
|
|
/**
|
|
* 이미지 마크다운 행을 이미지 데이터로 변환
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {Object|null} 이미지 데이터
|
|
*/
|
|
const parseImageLine = (line) => parseImageMarkdownLine(line)
|
|
|
|
/**
|
|
* 인용 마커 줄인지 확인한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {boolean}
|
|
*/
|
|
const isQuoteMarkerLine = (line) => {
|
|
const trimmed = String(line ?? '').trim()
|
|
return trimmed === '>' || /^>\s/.test(trimmed)
|
|
}
|
|
|
|
/**
|
|
* 인용 마커를 제거한 본문을 반환한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {string} 인용 본문
|
|
*/
|
|
const getQuoteLineBody = (line) => String(line ?? '').trim().replace(/^>\s?/, '')
|
|
|
|
/**
|
|
* 인용 옵션 줄을 파싱한다.
|
|
* @param {string} value - 인용 본문 줄
|
|
* @returns {{ quoteBackground: string }|null} 인용 옵션
|
|
*/
|
|
const parseQuoteOptions = (value) => {
|
|
const raw = String(value ?? '').trim()
|
|
const bracketMatch = raw.match(/^\[!(.+)\]$/)
|
|
const braceMatch = raw.match(/^\{(.+)\}$/)
|
|
const optionSource = bracketMatch?.[1] || braceMatch?.[1] || ''
|
|
|
|
if (!optionSource) {
|
|
return null
|
|
}
|
|
|
|
const tokens = optionSource.trim().split(/\s+/)
|
|
let quoteBackground = ''
|
|
|
|
tokens.forEach((token) => {
|
|
const [key, rawOptionValue] = token.split('=')
|
|
const optionValue = String(rawOptionValue || '').trim()
|
|
|
|
if (key?.toLowerCase() === 'bg' && QUOTE_BACKGROUND_OPTIONS.includes(optionValue)) {
|
|
quoteBackground = optionValue
|
|
}
|
|
})
|
|
|
|
return quoteBackground ? { quoteBackground } : null
|
|
}
|
|
|
|
/**
|
|
* 불릿 목록 마커 줄인지 확인한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {boolean}
|
|
*/
|
|
const isBulletListMarkerLine = (line) => {
|
|
const trimmed = String(line ?? '').trim()
|
|
return trimmed === '-' || /^-\s/.test(trimmed)
|
|
}
|
|
|
|
/**
|
|
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {boolean} 블록 시작 여부
|
|
*/
|
|
const isMarkdownBlockStart = (line) => {
|
|
const trimmedLine = line.trim()
|
|
|
|
return trimmedLine === BLANK_PARAGRAPH_MARKER ||
|
|
trimmedLine === '>>>' ||
|
|
trimmedLine === ':::bookmark' ||
|
|
trimmedLine === ':::signup' ||
|
|
trimmedLine === ':::gallery' ||
|
|
trimmedLine === ':::video' ||
|
|
trimmedLine === ':::audio' ||
|
|
trimmedLine === ':::file' ||
|
|
trimmedLine === ':::embed' ||
|
|
trimmedLine.startsWith(':::callout') ||
|
|
trimmedLine.startsWith(':::toggle') ||
|
|
trimmedLine.startsWith('```') ||
|
|
trimmedLine === '---' ||
|
|
isStandaloneUrlLine(trimmedLine) ||
|
|
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
|
trimmedLine.startsWith('> ') ||
|
|
/^- /.test(trimmedLine) ||
|
|
/^\d+\.\s+/.test(trimmedLine) ||
|
|
Boolean(parseImageLine(trimmedLine))
|
|
}
|
|
|
|
/**
|
|
* 문단 행에서 hard break 표식(레거시)을 제거한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {string} 정리된 문단 행
|
|
*/
|
|
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
|
|
|
|
/**
|
|
* 단독 URL 행인지 확인한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {boolean} 단독 URL 여부
|
|
*/
|
|
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 - 렌더링 블록
|
|
* @returns {string} Tailwind 높이 클래스
|
|
*/
|
|
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
|
|
|
|
/**
|
|
* 닫힘 표식까지의 행 목록을 반환
|
|
* @param {Array<string>} lines - 전체 마크다운 행
|
|
* @param {number} startIndex - 본문 시작 인덱스
|
|
* @returns {{contentLines: Array<string>, endLine: number, nextIndex: number}} 블록 본문과 다음 인덱스
|
|
*/
|
|
const collectFencedLines = (lines, startIndex) => {
|
|
const contentLines = []
|
|
let index = startIndex
|
|
|
|
while (index < lines.length && lines[index].trim() !== ':::') {
|
|
contentLines.push(lines[index])
|
|
index += 1
|
|
}
|
|
|
|
return {
|
|
contentLines,
|
|
endLine: index < lines.length ? index : Math.max(startIndex - 1, 0),
|
|
nextIndex: index < lines.length ? index + 1 : lines.length
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 북마크 fenced 블록 본문에서 URL·제목·설명·썸네일을 파싱한다.
|
|
* @param {string} raw - fenced 내부 텍스트
|
|
* @returns {{url: string, title: string, description: string, thumbnail: string}} 북마크 메타
|
|
*/
|
|
const parseBookmarkMeta = (raw) => {
|
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
const meta = {
|
|
url: '',
|
|
title: '',
|
|
description: '',
|
|
thumbnail: ''
|
|
}
|
|
|
|
for (const line of lines) {
|
|
const kv = line.match(/^(\w+)=(.*)$/)
|
|
|
|
if (kv) {
|
|
const key = kv[1].toLowerCase()
|
|
const val = kv[2].trim()
|
|
|
|
if (key === 'url') {
|
|
meta.url = val
|
|
} else if (key === 'title') {
|
|
meta.title = val
|
|
} else if (key === 'description' || key === 'desc') {
|
|
meta.description = val
|
|
} else if (key === 'thumbnail' || key === 'image') {
|
|
meta.thumbnail = val
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if (!meta.url && /^https?:\/\//i.test(line)) {
|
|
meta.url = line
|
|
continue
|
|
}
|
|
|
|
if (meta.url && !meta.title) {
|
|
meta.title = line
|
|
continue
|
|
}
|
|
|
|
if (meta.url && meta.title && !meta.description) {
|
|
meta.description = line
|
|
}
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
/**
|
|
* 회원가입(CTA) fenced 블록 본문에서 표시 문구를 파싱한다.
|
|
* @param {string} raw - fenced 내부 텍스트
|
|
* @returns {{title: string, description: string, button: string, placeholder: string}} CTA 메타
|
|
*/
|
|
const parseSignupMeta = (raw) => {
|
|
const meta = {
|
|
title: '뉴스레터에 가입하세요',
|
|
description: '새 글이 올라오면 받아보실 수 있어요.',
|
|
button: '구독하기',
|
|
placeholder: 'you@example.com'
|
|
}
|
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
|
|
for (const line of lines) {
|
|
const kv = line.match(/^(\w+)=(.*)$/)
|
|
|
|
if (!kv) {
|
|
continue
|
|
}
|
|
|
|
const key = kv[1].toLowerCase()
|
|
const val = kv[2].trim()
|
|
|
|
if (key === 'title') {
|
|
meta.title = val
|
|
} else if (key === 'description' || key === 'desc') {
|
|
meta.description = val
|
|
} else if (key === 'button') {
|
|
meta.button = val
|
|
} else if (key === 'placeholder') {
|
|
meta.placeholder = val
|
|
}
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
/**
|
|
* 미디어 fenced 블록 본문에서 URL과 표시 메타를 파싱한다.
|
|
* @param {string} raw - fenced 내부 텍스트
|
|
* @returns {{url: string, title: string, description: string, poster: string, caption: string, fileName: string, size: string}} 미디어 메타
|
|
*/
|
|
const parseMediaMeta = (raw) => {
|
|
const meta = {
|
|
url: '',
|
|
title: '',
|
|
description: '',
|
|
poster: '',
|
|
caption: '',
|
|
fileName: '',
|
|
size: ''
|
|
}
|
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
|
|
for (const line of lines) {
|
|
const kv = line.match(/^(\w+)=(.*)$/)
|
|
|
|
if (kv) {
|
|
const key = kv[1].toLowerCase()
|
|
const val = kv[2].trim()
|
|
|
|
if (key === 'url' || key === 'src') {
|
|
meta.url = val
|
|
} else if (key === 'title') {
|
|
meta.title = val
|
|
} else if (key === 'description' || key === 'desc') {
|
|
meta.description = val
|
|
} else if (key === 'poster' || key === 'thumbnail') {
|
|
meta.poster = val
|
|
} else if (key === 'caption') {
|
|
meta.caption = val
|
|
} else if (key === 'name' || key === 'filename' || key === 'file') {
|
|
meta.fileName = val
|
|
} else if (key === 'size') {
|
|
meta.size = val
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if (!meta.url && (/^https?:\/\//i.test(line) || line.startsWith('/'))) {
|
|
meta.url = line
|
|
continue
|
|
}
|
|
|
|
if (meta.url && !meta.title) {
|
|
meta.title = line
|
|
continue
|
|
}
|
|
|
|
if (meta.url && meta.title && !meta.description) {
|
|
meta.description = line
|
|
}
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
/**
|
|
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
|
* @param {string} markdown - 마크다운 문자열
|
|
* @returns {Array<Object>} 블록 목록
|
|
*/
|
|
const parseMarkdownBlocks = (markdown) => {
|
|
const lines = markdown.split('\n')
|
|
const blocks = []
|
|
let index = 0
|
|
|
|
while (index < lines.length) {
|
|
const line = lines[index]
|
|
const trimmedLine = line.trim()
|
|
|
|
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
|
|
const startLine = index
|
|
blocks.push(attachSourceRange(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }), startLine, startLine))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (!trimmedLine) {
|
|
const startLine = index
|
|
blocks.push(attachSourceRange(createBlock('spacer', '', null, `block-${blocks.length}`), startLine, startLine))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === '>>>') {
|
|
const startLine = index
|
|
const contentLines = []
|
|
index += 1
|
|
|
|
while (index < lines.length && lines[index].trim() !== '<<<') {
|
|
contentLines.push(lines[index])
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(attachSourceRange(
|
|
createBlock('quote', contentLines.join('\n'), null, `block-${blocks.length}`, { variant: 'alt' }),
|
|
startLine,
|
|
index - 1
|
|
))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::bookmark') {
|
|
const startLine = index
|
|
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
|
|
|
|
if (bookmarkMeta.url) {
|
|
blocks.push(attachSourceRange(
|
|
createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }),
|
|
startLine,
|
|
endLine
|
|
))
|
|
}
|
|
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::signup') {
|
|
const startLine = index
|
|
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const signupMeta = parseSignupMeta(contentLines.join('\n'))
|
|
blocks.push(attachSourceRange(
|
|
createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }),
|
|
startLine,
|
|
endLine
|
|
))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::gallery') {
|
|
const startLine = index
|
|
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const images = []
|
|
|
|
contentLines.forEach((contentLine) => {
|
|
const image = parseImageLine(contentLine)
|
|
if (image) {
|
|
images.push(image)
|
|
}
|
|
})
|
|
|
|
blocks.push(attachSourceRange(createBlock('gallery', '', null, `block-${blocks.length}`, {
|
|
images
|
|
}), startLine, endLine))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if ([':::video', ':::audio', ':::file'].includes(trimmedLine)) {
|
|
const startLine = index
|
|
const blockType = trimmedLine.replace(':::', '')
|
|
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const mediaMeta = parseMediaMeta(contentLines.join('\n'))
|
|
|
|
blocks.push(attachSourceRange(
|
|
createBlock(blockType, '', null, `block-${blocks.length}`, { meta: mediaMeta }),
|
|
startLine,
|
|
endLine
|
|
))
|
|
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith(':::callout')) {
|
|
const startLine = index
|
|
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
|
blocks.push(attachSourceRange(
|
|
createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)),
|
|
startLine,
|
|
endLine
|
|
))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith(':::toggle')) {
|
|
const startLine = index
|
|
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const toggleOptions = parseToggleOpenerLine(trimmedLine)
|
|
blocks.push(attachSourceRange(
|
|
createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, toggleOptions),
|
|
startLine,
|
|
endLine
|
|
))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::embed') {
|
|
const startLine = index
|
|
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
|
blocks.push(attachSourceRange(
|
|
createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
|
|
startLine,
|
|
endLine
|
|
))
|
|
index = nextIndex
|
|
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(
|
|
createBlock('embed', '', null, `block-${blocks.length}`, { url: trimmedLine }),
|
|
startLine,
|
|
startLine
|
|
))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
const image = parseImageLine(trimmedLine)
|
|
|
|
if (image) {
|
|
const startLine = index
|
|
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, image), startLine, startLine))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith('```')) {
|
|
const startLine = index
|
|
const fenceOptions = parseCodeFenceLine(trimmedLine) || { language: '', showLineNumbers: true }
|
|
const codeLines = []
|
|
index += 1
|
|
|
|
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
|
codeLines.push(lines[index])
|
|
index += 1
|
|
}
|
|
|
|
if (index >= lines.length) {
|
|
index = startLine
|
|
} else {
|
|
blocks.push(attachSourceRange(
|
|
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, {
|
|
codeLanguage: fenceOptions.language,
|
|
codeShowLineNumbers: fenceOptions.showLineNumbers
|
|
}),
|
|
startLine,
|
|
index
|
|
))
|
|
index += 1
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (trimmedLine === '---') {
|
|
const startLine = index
|
|
blocks.push(attachSourceRange(
|
|
createBlock('divider', '', null, `block-${blocks.length}`),
|
|
startLine,
|
|
startLine
|
|
))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/)
|
|
|
|
if (headingMatch) {
|
|
const startLine = index
|
|
blocks.push(attachSourceRange(
|
|
createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`),
|
|
startLine,
|
|
startLine
|
|
))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (isQuoteMarkerLine(line)) {
|
|
const startLine = index
|
|
const rawQuoteLines = []
|
|
|
|
while (index < lines.length && isQuoteMarkerLine(lines[index])) {
|
|
rawQuoteLines.push(getQuoteLineBody(lines[index]))
|
|
index += 1
|
|
}
|
|
|
|
const quoteOptions = parseQuoteOptions(rawQuoteLines[0])
|
|
const quoteLines = quoteOptions ? rawQuoteLines.slice(1) : rawQuoteLines
|
|
const contentStartLine = startLine + (quoteOptions ? 1 : 0)
|
|
|
|
blocks.push(attachSourceRange(
|
|
createBlock('quote', (quoteLines.length ? quoteLines : ['']).join('\n'), null, `block-${blocks.length}`, {
|
|
...(quoteOptions || {}),
|
|
meta: {
|
|
quoteContentStartLine: contentStartLine
|
|
}
|
|
}),
|
|
startLine,
|
|
index - 1
|
|
))
|
|
continue
|
|
}
|
|
|
|
if (isBulletListMarkerLine(line)) {
|
|
const startLine = index
|
|
const items = []
|
|
|
|
while (index < lines.length && isBulletListMarkerLine(lines[index])) {
|
|
items.push(lines[index].trim().replace(/^-\s?/, ''))
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(attachSourceRange(createBlock('list', items, null, `block-${blocks.length}`), startLine, index - 1))
|
|
continue
|
|
}
|
|
|
|
if (/^\d+\.\s*/.test(trimmedLine)) {
|
|
const startLine = index
|
|
const items = []
|
|
const listNumbers = []
|
|
|
|
while (index < lines.length && /^\d+\.\s*/.test(lines[index].trim())) {
|
|
const match = lines[index].trim().match(/^(\d+)\.\s*(.*)$/)
|
|
listNumbers.push(Number(match[1]))
|
|
items.push(String(match[2] ?? '').trim())
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(attachSourceRange(
|
|
createBlock('list', items, null, `block-${blocks.length}`, { ordered: true, listNumbers }),
|
|
startLine,
|
|
index - 1
|
|
))
|
|
continue
|
|
}
|
|
|
|
blocks.push(attachSourceRange(
|
|
createBlock('paragraph', cleanParagraphLine(line), null, `block-${blocks.length}`),
|
|
index,
|
|
index
|
|
))
|
|
index += 1
|
|
}
|
|
|
|
return blocks
|
|
}
|
|
|
|
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
|
const normalizedInArticleAdCode = computed(() => String(props.inArticleAdCode || '').trim())
|
|
|
|
/**
|
|
* 블록의 텍스트 길이를 계산한다.
|
|
* @param {Object} block - 마크다운 블록
|
|
* @returns {number} 공백을 제외한 텍스트 길이
|
|
*/
|
|
const getBlockPlainTextLength = (block) => {
|
|
const value = Array.isArray(block.text) ? block.text.join(' ') : String(block.text || '')
|
|
return value.replace(/\s+/g, '').length
|
|
}
|
|
|
|
/**
|
|
* 본문 인아티클 광고를 삽입할 수 있는 문단 블록인지 확인한다.
|
|
* @param {Object} block - 마크다운 블록
|
|
* @returns {boolean} 삽입 후보 여부
|
|
*/
|
|
const isInArticleAdCandidateBlock = (block) => {
|
|
return block?.type === 'paragraph' && getBlockPlainTextLength(block) >= 12
|
|
}
|
|
|
|
/**
|
|
* 본문 인아티클 광고를 삽입할 블록 ID를 반환한다.
|
|
* @returns {string} 삽입 기준 블록 ID
|
|
*/
|
|
const inArticleAdAfterBlockId = computed(() => {
|
|
if (props.interactive || !normalizedInArticleAdCode.value) {
|
|
return ''
|
|
}
|
|
|
|
const currentBlocks = blocks.value
|
|
const contentBlocks = currentBlocks.filter((block) => !['spacer', 'divider'].includes(block.type))
|
|
|
|
if (contentBlocks.length < 10) {
|
|
return ''
|
|
}
|
|
|
|
const candidates = currentBlocks
|
|
.map((block, index) => ({ block, index }))
|
|
.filter((item) => isInArticleAdCandidateBlock(item.block))
|
|
|
|
if (candidates.length < 6) {
|
|
return ''
|
|
}
|
|
|
|
const minIndex = Math.max(3, Math.floor(currentBlocks.length * 0.2))
|
|
const maxIndex = Math.min(currentBlocks.length - 4, Math.floor(currentBlocks.length * 0.75))
|
|
|
|
if (maxIndex < minIndex) {
|
|
return ''
|
|
}
|
|
|
|
const targetIndex = Math.floor((currentBlocks.length - 1) * 0.4)
|
|
const rangedCandidates = candidates.filter((item) => item.index >= minIndex && item.index <= maxIndex)
|
|
|
|
if (!rangedCandidates.length) {
|
|
return ''
|
|
}
|
|
|
|
const selected = rangedCandidates.reduce((closest, item) => {
|
|
return Math.abs(item.index - targetIndex) < Math.abs(closest.index - targetIndex) ? item : closest
|
|
}, rangedCandidates[0])
|
|
|
|
return selected.block.id
|
|
})
|
|
const headingIdsByBlock = computed(() => {
|
|
const createHeadingId = createHeadingIdFactory()
|
|
const ids = {}
|
|
|
|
for (const block of blocks.value) {
|
|
if (block.type === 'heading' && block.level >= 1 && block.level <= 3) {
|
|
ids[block.id] = createHeadingId(block.text)
|
|
}
|
|
}
|
|
|
|
return ids
|
|
})
|
|
|
|
/** @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
|
|
}
|
|
|
|
const line = pendingFocusLine.value
|
|
const position = pendingFocusPosition.value
|
|
const offset = pendingFocusOffset.value
|
|
pendingFocusLine.value = null
|
|
pendingFocusPosition.value = 'auto'
|
|
pendingFocusOffset.value = null
|
|
|
|
nextTick(() => {
|
|
nextTick(() => {
|
|
focusEditableAtLine(line, 0, position, offset)
|
|
})
|
|
})
|
|
})
|
|
|
|
/**
|
|
* 지정한 원본 줄의 편집 영역에 포커스를 둔다.
|
|
* @param {number} lineIndex - 줄 번호(0-based)
|
|
* @param {number} [attempt=0] - DOM 탐색 재시도 횟수
|
|
* @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치
|
|
* @param {number|null} [caretOffset=null] - 텍스트 오프셋
|
|
* @param {'nearest'|'center'} [scrollBlock='nearest'] - 스크롤 정렬 위치
|
|
* @returns {void}
|
|
*/
|
|
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null, scrollBlock = 'nearest') => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
const matches = rendererRootRef.value
|
|
? [...rendererRootRef.value.querySelectorAll(`[data-source-line="${lineIndex}"]`)]
|
|
: []
|
|
const rangedMatches = rendererRootRef.value
|
|
? [...rendererRootRef.value.querySelectorAll('[data-source-line][data-source-line-end]')]
|
|
.filter((node) => {
|
|
const start = Number(node.getAttribute('data-source-line'))
|
|
const end = Number(node.getAttribute('data-source-line-end'))
|
|
|
|
return node.getAttribute('contenteditable') === 'true'
|
|
&& Number.isInteger(start)
|
|
&& Number.isInteger(end)
|
|
&& start <= lineIndex
|
|
&& lineIndex <= end
|
|
})
|
|
: []
|
|
|
|
const element = matches.find((node) => node.getAttribute('contenteditable') === 'true')
|
|
|| matches[0]
|
|
|| rangedMatches[0]
|
|
|| null
|
|
|
|
if (!element) {
|
|
if (attempt < 8) {
|
|
requestAnimationFrame(() => {
|
|
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset, scrollBlock)
|
|
})
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
const line = getMarkdownLine(lineIndex)
|
|
const isBlankMarker = /^>\s*$/.test(line) || /^[-*+]\s*$/.test(line) || /^\d+\.\s*$/.test(line.trim())
|
|
const elementSourceLine = Number(element.getAttribute('data-source-line'))
|
|
const elementSourceLineEnd = Number(element.getAttribute('data-source-line-end'))
|
|
const hasSourceLineRange = Number.isInteger(elementSourceLine) && Number.isInteger(elementSourceLineEnd)
|
|
const spansMultipleLines = hasSourceLineRange && elementSourceLineEnd > elementSourceLine
|
|
const isWithinSourceLineRange = hasSourceLineRange
|
|
&& lineIndex >= elementSourceLine
|
|
&& lineIndex <= elementSourceLineEnd
|
|
const useMultilineCaret = spansMultipleLines && isWithinSourceLineRange
|
|
|
|
/**
|
|
* 멀티라인 편집 영역에서 원본 줄에 해당하는 텍스트 오프셋을 반환한다.
|
|
* @returns {number} 루트 기준 오프셋
|
|
*/
|
|
const getCaretOffsetForSourceLine = () => {
|
|
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
|
const textLines = text.length ? text.split('\n') : ['']
|
|
const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1))
|
|
|
|
return textLines
|
|
.slice(0, targetLineIndex)
|
|
.reduce((sum, textLine) => sum + textLine.length + 1, 0)
|
|
}
|
|
|
|
if (!useMultilineCaret && (isBlankMarker || !line.trim())) {
|
|
if (element.getAttribute('contenteditable') === 'true') {
|
|
element.textContent = ''
|
|
element.innerHTML = ''
|
|
}
|
|
}
|
|
|
|
element.focus({ preventScroll: true })
|
|
|
|
if (element.getAttribute('contenteditable') !== 'true') {
|
|
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
|
return
|
|
}
|
|
|
|
if (typeof caretOffset === 'number' && caretOffset >= 0) {
|
|
if (useMultilineCaret) {
|
|
setEditableCaretOffset(/** @type {HTMLElement} */ (element), getCaretOffsetForSourceLine() + caretOffset)
|
|
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
|
return
|
|
}
|
|
|
|
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
|
|
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
|
return
|
|
}
|
|
|
|
if (useMultilineCaret) {
|
|
const lineOffset = getCaretOffsetForSourceLine()
|
|
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
|
const textLines = text.length ? text.split('\n') : ['']
|
|
const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1))
|
|
const lineText = textLines[targetLineIndex] ?? ''
|
|
const nextOffset = cursorPosition === 'end'
|
|
? lineOffset + lineText.length
|
|
: lineOffset
|
|
|
|
setEditableCaretOffset(/** @type {HTMLElement} */ (element), nextOffset)
|
|
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
|
return
|
|
}
|
|
|
|
if (cursorPosition === 'start' || (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))) {
|
|
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
|
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
|
return
|
|
}
|
|
|
|
if (cursorPosition === 'end') {
|
|
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
|
setEditableCaretOffset(/** @type {HTMLElement} */ (element), text.length)
|
|
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
|
return
|
|
}
|
|
|
|
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
|
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
|
}
|
|
|
|
/**
|
|
* 선택 확장 대상 컨테이너를 반환한다.
|
|
* @param {{ navigationScope?: string, sourceLine?: number }} payload - 확장 요청
|
|
* @returns {HTMLElement|null} 컨테이너
|
|
*/
|
|
const resolveSelectionContainer = (payload) => {
|
|
if (!rendererRootRef.value) {
|
|
return null
|
|
}
|
|
|
|
if (payload.navigationScope === 'parent' && typeof payload.sourceLine === 'number') {
|
|
const current = rendererRootRef.value.querySelector(`[data-source-line="${payload.sourceLine}"]`)
|
|
return current?.closest('[data-editable-scope]') || rendererRootRef.value
|
|
}
|
|
|
|
return rendererRootRef.value
|
|
}
|
|
|
|
/**
|
|
* Shift 선택을 인접 편집 블록으로 확장한다.
|
|
* @param {{ sourceLine: number, direction: number, column?: number, navigationScope?: string }} payload - 확장 요청
|
|
* @returns {void}
|
|
*/
|
|
const onExtendLiveSelection = (payload) => {
|
|
if (!props.interactive || typeof payload?.sourceLine !== 'number' || !payload.direction) {
|
|
return
|
|
}
|
|
|
|
const container = resolveSelectionContainer(payload)
|
|
|
|
extendSelectionAcrossBlocks({
|
|
container,
|
|
sourceLine: payload.sourceLine,
|
|
direction: payload.direction,
|
|
column: payload.column
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 라이브 본문의 모든 편집 가능 텍스트를 선택한다.
|
|
* @returns {void}
|
|
*/
|
|
const selectAllLiveDocument = () => {
|
|
if (!props.interactive || !rendererRootRef.value) {
|
|
return
|
|
}
|
|
|
|
const elements = getSelectableEditableElements(rendererRootRef.value)
|
|
selectAllEditableElements(elements)
|
|
}
|
|
|
|
/**
|
|
* 교차 블록·전체 선택 삭제를 마크다운에 반영한다.
|
|
* @returns {boolean} 처리 여부
|
|
*/
|
|
const deleteLiveSelection = () => {
|
|
if (!props.interactive || !rendererRootRef.value) {
|
|
return false
|
|
}
|
|
|
|
const nextMarkdown = applyLiveSelectionDelete(props.content, rendererRootRef.value)
|
|
|
|
if (nextMarkdown === null) {
|
|
return false
|
|
}
|
|
|
|
emit('content-replace', { value: nextMarkdown })
|
|
collapseLiveSelection()
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* 라이브 선택 삭제 단축키를 처리한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onRendererSelectionKeydown = (event) => {
|
|
if (!props.interactive || !isLiveSelectionDeleteKey(event)) {
|
|
return
|
|
}
|
|
|
|
const isCut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'x'
|
|
|
|
if (isCut && import.meta.client) {
|
|
const selectedText = window.getSelection()?.toString() ?? ''
|
|
|
|
if (selectedText) {
|
|
navigator.clipboard?.writeText(selectedText).catch(() => {})
|
|
}
|
|
}
|
|
|
|
if (!deleteLiveSelection()) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
|
|
provide(LIVE_SELECTION_BRIDGE_KEY, {
|
|
extendSelection: (payload) => {
|
|
if (props.interactive) {
|
|
onExtendLiveSelection(payload)
|
|
}
|
|
},
|
|
selectDocument: () => {
|
|
if (props.interactive) {
|
|
selectAllLiveDocument()
|
|
}
|
|
},
|
|
deleteSelection: () => {
|
|
if (props.interactive) {
|
|
return deleteLiveSelection()
|
|
}
|
|
|
|
return false
|
|
}
|
|
})
|
|
|
|
defineExpose({
|
|
focusEditableAtLine,
|
|
selectAllLiveDocument
|
|
})
|
|
|
|
/**
|
|
* 인라인 편집 원문 모드 표시 상태를 갱신한다.
|
|
* @param {{ sourceLine: number, active: boolean }} payload - 줄 번호·활성 여부
|
|
* @returns {void}
|
|
*/
|
|
const onInlineRawMode = ({ sourceLine, active }) => {
|
|
if (typeof sourceLine !== 'number') {
|
|
return
|
|
}
|
|
|
|
const next = new Set(rawEditingSourceLines.value)
|
|
|
|
if (active) {
|
|
next.add(sourceLine)
|
|
} else {
|
|
next.delete(sourceLine)
|
|
}
|
|
|
|
rawEditingSourceLines.value = next
|
|
}
|
|
|
|
/**
|
|
* 제목 레벨별 편집 영역 클래스
|
|
* @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 {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 - 원문 모드 여부
|
|
* @returns {string} 마크다운 줄
|
|
*/
|
|
const formatQuoteLine = (value, raw) => {
|
|
if (raw) {
|
|
if (!hasQuoteMarker(value)) {
|
|
return String(value ?? '').trim()
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
const clean = stripQuoteMarker(value)
|
|
return clean ? `> ${clean}` : '> '
|
|
}
|
|
|
|
/**
|
|
* 목록 항목 마크다운 줄을 만든다.
|
|
* @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 - 인용 블록
|
|
* @returns {number} 본문 시작 줄
|
|
*/
|
|
const getQuoteContentStartLine = (block) => {
|
|
if (typeof block.meta?.quoteContentStartLine === 'number') {
|
|
return block.meta.quoteContentStartLine
|
|
}
|
|
|
|
return block.meta.startLine
|
|
}
|
|
|
|
/**
|
|
* 인용 블록을 편집 가능한 줄 단위로 분리한다.
|
|
* @param {Object} block - 인용 블록
|
|
* @returns {Array<{ text: string, sourceLine: number, sourceIndex: number }>} 줄 목록
|
|
*/
|
|
const getQuoteLineEntries = (block) => {
|
|
const contentStartLine = getQuoteContentStartLine(block)
|
|
const endLine = block.meta.endLine ?? block.meta.startLine
|
|
const lineCount = Math.max(1, endLine - contentStartLine + 1)
|
|
const fromText = String(block.text ?? '').split('\n')
|
|
|
|
while (fromText.length < lineCount) {
|
|
fromText.push('')
|
|
}
|
|
|
|
return fromText.slice(0, lineCount).map((text, index) => ({
|
|
text,
|
|
sourceLine: contentStartLine + index,
|
|
sourceIndex: contentStartLine + index - block.meta.startLine
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* 코드 블록 줄 번호 목록을 만든다.
|
|
* @param {Object} block - 코드 블록
|
|
* @returns {number[]} 줄 번호(1부터)
|
|
*/
|
|
const getCodeLineNumbers = (block) => {
|
|
const text = String(block.text ?? '')
|
|
const lineCount = text.length ? text.split('\n').length : 1
|
|
|
|
return Array.from({ length: lineCount }, (_, index) => index + 1)
|
|
}
|
|
|
|
/**
|
|
* 인라인 편집 결과를 마크다운 줄로 반영한다.
|
|
* @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 {Event} event - 포커스 또는 포인터 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const emitLiveLineFocus = (event) => {
|
|
if (!props.interactive) {
|
|
return
|
|
}
|
|
|
|
const target = event.target
|
|
|
|
if (!(target instanceof Element)) {
|
|
return
|
|
}
|
|
|
|
const sourceElement = target.closest('[data-source-line]')
|
|
const sourceLine = Number(sourceElement?.getAttribute('data-source-line'))
|
|
|
|
if (!Number.isInteger(sourceLine) || sourceLine < 0) {
|
|
return
|
|
}
|
|
|
|
emit('line-focus', sourceLine)
|
|
}
|
|
|
|
/**
|
|
* 라이브 편집 영역을 벗어난 포커스를 상위에 알린다.
|
|
* @param {FocusEvent} event - 포커스 이탈 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const emitLiveLineBlur = (event) => {
|
|
if (!props.interactive || !rendererRootRef.value) {
|
|
return
|
|
}
|
|
|
|
const nextTarget = event.relatedTarget
|
|
|
|
if (nextTarget instanceof Node && rendererRootRef.value.contains(nextTarget)) {
|
|
return
|
|
}
|
|
|
|
emit('line-blur')
|
|
}
|
|
|
|
/**
|
|
* 문단 인라인 편집 반영
|
|
* @param {Object} block - 블록
|
|
* @param {string} text - 편집된 텍스트
|
|
* @returns {void}
|
|
*/
|
|
/**
|
|
* 코드 블록 편집 반영
|
|
* @param {Object} block - 코드 블록
|
|
* @param {string[]} replacementLines - 마크다운 줄
|
|
* @returns {void}
|
|
*/
|
|
const onCodeBlockCommit = (block, replacementLines) => {
|
|
commitInlineBlockLines(block, replacementLines)
|
|
}
|
|
|
|
/**
|
|
* 코드 블록 마지막 줄에서 아래로 이탈 — 본문 저장 후 다음 문단 삽입
|
|
* @param {Object} block - 코드 블록
|
|
* @param {string|Object} payload - insert-below 페이로드
|
|
* @returns {void}
|
|
*/
|
|
const onCodeBlockInsertBelow = (block, payload) => {
|
|
const { value } = normalizeInsertBelowPayload(payload)
|
|
|
|
onCodeBlockCommit(block, buildCodeBlockLines({
|
|
language: block.codeLanguage,
|
|
showLineNumbers: block.codeShowLineNumbers,
|
|
body: value
|
|
}))
|
|
onInsertBelowBlock(block, { lines: [''] })
|
|
}
|
|
|
|
/**
|
|
* 블록 위에 기본 문단을 삽입한다.
|
|
* @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
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 편집 반영
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @param {string[]} replacementLines - 마크다운 줄
|
|
* @returns {void}
|
|
*/
|
|
const onCalloutBlockCommit = (block, replacementLines) => {
|
|
commitInlineBlockLines(block, replacementLines)
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 마지막 줄에서 아래로 이탈한다.
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @param {string|Object} payload - insert-below 페이로드
|
|
* @returns {void}
|
|
*/
|
|
const onCalloutBlockInsertBelow = (block, payload) => {
|
|
const { value } = normalizeInsertBelowPayload(payload)
|
|
const openingLine = getMarkdownLine(block.meta.startLine)
|
|
|
|
onCalloutBlockCommit(block, [
|
|
openingLine,
|
|
...String(value ?? '').replace(/\r/g, '').split('\n'),
|
|
':::'
|
|
])
|
|
pendingFocusPosition.value = 'start'
|
|
pendingFocusOffset.value = 0
|
|
onInsertBelowBlock(block, { lines: [''] })
|
|
}
|
|
|
|
/**
|
|
* 자식 편집기가 요청한 원본 줄에 포커스를 둔다.
|
|
* @param {{ line?: number, position?: 'start'|'end'|'auto', offset?: number|null }} payload - 포커스 요청
|
|
* @returns {void}
|
|
*/
|
|
const onEditorFocusLine = (payload) => {
|
|
if (typeof payload?.line !== 'number') {
|
|
return
|
|
}
|
|
|
|
pendingFocusLine.value = payload.line
|
|
pendingFocusPosition.value = payload.position || 'auto'
|
|
pendingFocusOffset.value = typeof payload.offset === 'number' ? payload.offset : null
|
|
}
|
|
|
|
/**
|
|
* 토글 편집 반영
|
|
* @param {Object} block - 토글 블록
|
|
* @param {string[]} replacementLines - 마크다운 줄
|
|
* @returns {void}
|
|
*/
|
|
const onToggleBlockCommit = (block, replacementLines) => {
|
|
commitInlineBlockLines(block, replacementLines)
|
|
}
|
|
|
|
/**
|
|
* 토글 본문 마지막 줄에서 아래로 이탈
|
|
* @param {Object} block - 토글 블록
|
|
* @param {string|Object} payload - insert-below 페이로드
|
|
* @returns {void}
|
|
*/
|
|
const onToggleBlockInsertBelow = (block, payload) => {
|
|
const { value } = normalizeInsertBelowPayload(payload)
|
|
|
|
onToggleBlockCommit(block, buildToggleBlockLines({
|
|
title: block.title,
|
|
defaultOpen: block.defaultOpen,
|
|
body: value
|
|
}))
|
|
pendingFocusPosition.value = 'start'
|
|
pendingFocusOffset.value = 0
|
|
onInsertBelowBlock(block, { lines: [''] })
|
|
}
|
|
|
|
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: true }) : 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 - 이미지 블록
|
|
* @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.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) => {
|
|
commitInlineBlockLines(block, buildQuoteBlockLines(block, payload))
|
|
}
|
|
|
|
/**
|
|
* 인용 블록 마지막 줄에서 아래로 이탈한다.
|
|
* @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 = String(value ?? '').replace(/\r/g, '').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} value - 원본 문자열
|
|
* @returns {Array<Array<{ type: string, text: string, href?: string }>>} 줄별 인라인 세그먼트
|
|
*/
|
|
const parseInlineSegmentLines = (value) => {
|
|
return String(value || '').split('\n').map(parseInlineSegments)
|
|
}
|
|
|
|
/**
|
|
* 라이트박스를 연다
|
|
* @param {Array<Object>} images - 이미지 목록
|
|
* @param {number} index - 시작 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const openLightbox = (images, index) => {
|
|
activeLightboxImages.value = images
|
|
activeLightboxIndex.value = index
|
|
}
|
|
|
|
/**
|
|
* 라이트박스를 닫는다
|
|
* @returns {void}
|
|
*/
|
|
const closeLightbox = () => {
|
|
activeLightboxImages.value = []
|
|
activeLightboxIndex.value = 0
|
|
}
|
|
|
|
/**
|
|
* 라이트박스 이전 이미지로 이동
|
|
* @returns {void}
|
|
*/
|
|
const showPreviousImage = () => {
|
|
activeLightboxIndex.value = activeLightboxIndex.value === 0
|
|
? activeLightboxImages.value.length - 1
|
|
: activeLightboxIndex.value - 1
|
|
}
|
|
|
|
/**
|
|
* 라이트박스 다음 이미지로 이동
|
|
* @returns {void}
|
|
*/
|
|
const showNextImage = () => {
|
|
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
|
|
}
|
|
|
|
/**
|
|
* 블록 앞 삽입 위치(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 {Object} payload - 드래그 payload
|
|
* @returns {void}
|
|
*/
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* 갤러리 드래그 종료
|
|
* @returns {void}
|
|
*/
|
|
const onGalleryDragEnd = () => {
|
|
clearLiveImageDragUi()
|
|
}
|
|
|
|
/**
|
|
* 갤러리 드래그 중 드롭 대상 셀 표시
|
|
* @param {DragEvent} event - 드래그 이벤트
|
|
* @param {Object} block - 갤러리 블록
|
|
* @param {number} imageIndex - 호버 중인 이미지 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const onGalleryDragOverItem = (event, block, imageIndex) => {
|
|
if (!props.interactive || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
blockInsertDropTarget.value = null
|
|
|
|
if (liveImageDragState.value.kind === 'image') {
|
|
event.dataTransfer.dropEffect = 'move'
|
|
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
|
|
imageBlockDropTarget.value = null
|
|
return
|
|
}
|
|
|
|
if (liveImageDragState.value.blockId !== block.id) {
|
|
return
|
|
}
|
|
|
|
if (liveImageDragState.value.imageIndex === imageIndex) {
|
|
galleryDropTarget.value = null
|
|
return
|
|
}
|
|
|
|
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
|
|
}
|
|
|
|
/**
|
|
* 갤러리 영역을 벗어나면 드롭 대상 표시를 해제한다.
|
|
* @param {DragEvent} event - 드래그 이벤트
|
|
* @param {Object} block - 갤러리 블록
|
|
* @returns {void}
|
|
*/
|
|
const onGalleryDragLeaveGallery = (event, block) => {
|
|
const related = event.relatedTarget
|
|
|
|
if (related && event.currentTarget.contains(related)) {
|
|
return
|
|
}
|
|
|
|
if (galleryDropTarget.value?.blockId === block.id) {
|
|
galleryDropTarget.value = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 갤러리 드롭으로 순서 변경
|
|
* @param {DragEvent} event - 드롭 이벤트
|
|
* @param {Object} block - 갤러리 블록
|
|
* @param {number} targetIndex - 대상 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const onGalleryDrop = (event, block, targetIndex) => {
|
|
if (!props.interactive || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
|
|
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) {
|
|
clearLiveImageDragUi()
|
|
return
|
|
}
|
|
|
|
const images = [...block.images]
|
|
const [moved] = images.splice(fromIndex, 1)
|
|
images.splice(targetIndex, 0, moved)
|
|
|
|
emit('gallery-reorder', {
|
|
startLine: block.meta?.startLine,
|
|
endLine: block.meta?.endLine,
|
|
images
|
|
})
|
|
|
|
clearLiveImageDragUi()
|
|
}
|
|
|
|
/**
|
|
* 라이트박스 키보드 조작(Esc 닫기, 좌우 이전·다음)
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const handleLightboxKeydown = (event) => {
|
|
if (!activeLightboxImage.value) {
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
closeLightbox()
|
|
return
|
|
}
|
|
|
|
if (activeLightboxImages.value.length <= 1) {
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowLeft') {
|
|
event.preventDefault()
|
|
showPreviousImage()
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowRight') {
|
|
event.preventDefault()
|
|
showNextImage()
|
|
}
|
|
}
|
|
|
|
watch(activeLightboxImage, (image) => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
if (image) {
|
|
window.addEventListener('keydown', handleLightboxKeydown)
|
|
} else {
|
|
window.removeEventListener('keydown', handleLightboxKeydown)
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (import.meta.client) {
|
|
window.removeEventListener('keydown', handleLightboxKeydown)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="rendererRootRef"
|
|
class="content-markdown-renderer"
|
|
@mousedown.capture="emitLiveLineFocus"
|
|
@focusin.capture="emitLiveLineFocus"
|
|
@focusout.capture="emitLiveLineBlur"
|
|
@keydown.capture="onRendererSelectionKeydown"
|
|
>
|
|
<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"
|
|
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">
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
|
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</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"
|
|
plain-text
|
|
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)"
|
|
/>
|
|
</ProseBlockquote>
|
|
<ProseBlockquote
|
|
v-else-if="block.type === 'quote'"
|
|
:variant="block.variant || 'default'"
|
|
:background="block.quoteBackground"
|
|
>
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
|
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</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-center 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-center 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">
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
|
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</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"
|
|
:alt="getImageAltAttribute(block)"
|
|
:caption="getImageDisplayCaption(block)"
|
|
:variant="block.width"
|
|
/>
|
|
<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="onEditorFocusLine"
|
|
/>
|
|
<ProseCallout
|
|
v-else-if="block.type === 'callout'"
|
|
:emoji-enabled="block.calloutEmojiEnabled"
|
|
:emoji="block.calloutEmoji"
|
|
:title="block.title"
|
|
:background="block.calloutBackground"
|
|
>
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
|
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</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>
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
|
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</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="block.type === 'embed' && interactive"
|
|
class="content-markdown-renderer__embed-live 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="임베드 블록"
|
|
@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="임베드 삭제"
|
|
@click.stop="deletePreviewCardBlock(block)"
|
|
>
|
|
삭제
|
|
</button>
|
|
<ProseEmbed :url="block.url" />
|
|
</section>
|
|
<ProseEmbed v-else-if="block.type === 'embed'" :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-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"
|
|
: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"
|
|
/>
|
|
<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">
|
|
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
|
<br v-if="lineIndex > 0">
|
|
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
|
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</template>
|
|
</p>
|
|
|
|
<SiteAdSlot
|
|
v-if="block.id === inArticleAdAfterBlockId"
|
|
class="content-markdown-renderer__in-article-ad my-8"
|
|
:code="normalizedInArticleAdCode"
|
|
location="post-in-article"
|
|
/>
|
|
</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"
|
|
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="activeLightboxImage.url"
|
|
:alt="getImageAltAttribute(activeLightboxImage)"
|
|
>
|
|
<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;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.content-markdown-renderer__list-marker--ordered {
|
|
text-align: right;
|
|
font-weight: 500;
|
|
font-variant-numeric: tabular-nums;
|
|
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>
|