본문 문단과 줄바꿈 처리 정리
This commit is contained in:
@@ -24,6 +24,11 @@ const isUploading = ref(false)
|
||||
const mediaPickerTarget = ref('image')
|
||||
const selectedMediaUrls = ref([])
|
||||
const selectedImageWidth = ref('regular')
|
||||
const lastSelectionState = ref({
|
||||
start: 0,
|
||||
end: 0,
|
||||
scrollTop: 0
|
||||
})
|
||||
|
||||
const imageWidthOptions = [
|
||||
{ value: 'regular', label: '기본' },
|
||||
@@ -154,26 +159,79 @@ const refreshCaretLogicalLine = () => {
|
||||
const pos = Math.min(textarea.selectionStart, value.length)
|
||||
const lineIndex = value.slice(0, pos).split('\n').length - 1
|
||||
|
||||
lastSelectionState.value = {
|
||||
start: Math.min(textarea.selectionStart, value.length),
|
||||
end: Math.min(textarea.selectionEnd, value.length),
|
||||
scrollTop: textarea.scrollTop
|
||||
}
|
||||
activeLogicalLineIndex.value = Math.max(0, lineIndex)
|
||||
syncGutterScroll()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* textarea 스크롤 시 거터만 동기화한다.
|
||||
* textarea 스크롤 시 선택 위치를 기억하고 거터를 동기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onTextareaScroll = () => {
|
||||
rememberTextareaSelection()
|
||||
syncGutterScroll()
|
||||
}
|
||||
|
||||
/**
|
||||
* textarea의 선택 영역과 스크롤 위치를 기억한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const rememberTextareaSelection = () => {
|
||||
const textarea = textareaRef.value
|
||||
const value = markdownValue.value ?? ''
|
||||
|
||||
if (!textarea) {
|
||||
lastSelectionState.value = {
|
||||
start: value.length,
|
||||
end: value.length,
|
||||
scrollTop: 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastSelectionState.value = {
|
||||
start: Math.min(textarea.selectionStart, value.length),
|
||||
end: Math.min(textarea.selectionEnd, value.length),
|
||||
scrollTop: textarea.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const restoreTextareaFocus = () => {
|
||||
nextTick(() => {
|
||||
const textarea = textareaRef.value
|
||||
const value = markdownValue.value ?? ''
|
||||
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
|
||||
const start = Math.min(lastSelectionState.value.start, value.length)
|
||||
const end = Math.min(lastSelectionState.value.end, value.length)
|
||||
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(start, end)
|
||||
textarea.scrollTop = lastSelectionState.value.scrollTop
|
||||
refreshCaretLogicalLine()
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
refreshCaretLogicalLine()
|
||||
})
|
||||
|
||||
watch(activeMode, (mode) => {
|
||||
if (mode === 'write') {
|
||||
refreshCaretLogicalLine()
|
||||
restoreTextareaFocus()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,9 +245,30 @@ watch(activeMode, (mode) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleEditorMode = () => {
|
||||
if (activeMode.value === 'write') {
|
||||
rememberTextareaSelection()
|
||||
}
|
||||
|
||||
activeMode.value = activeMode.value === 'write' ? 'preview' : 'write'
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성/미리보기 모드를 지정한다.
|
||||
* @param {'write'|'preview'} mode - 전환할 모드
|
||||
* @returns {void}
|
||||
*/
|
||||
const setEditorMode = (mode) => {
|
||||
if (activeMode.value === mode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeMode.value === 'write') {
|
||||
rememberTextareaSelection()
|
||||
}
|
||||
|
||||
activeMode.value = mode
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
/**
|
||||
* document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다.
|
||||
@@ -311,6 +390,21 @@ const replaceSelection = (replacement, cursorOffset = replacement.length, select
|
||||
setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter 입력을 문단 분리 규칙에 맞게 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {boolean} 직접 처리했는지 여부
|
||||
*/
|
||||
const handleParagraphEnter = (event) => {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.isComposing) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
replaceSelection('\n\n')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
|
||||
* @param {string} snippet - 삽입할 마크다운
|
||||
@@ -882,6 +976,10 @@ const handleDrop = async (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleKeydown = (event) => {
|
||||
if (handleParagraphEnter(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(event.metaKey || event.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
@@ -956,7 +1054,7 @@ const handleKeydown = (event) => {
|
||||
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
||||
:class="activeMode === 'write' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
||||
type="button"
|
||||
@click="activeMode = 'write'"
|
||||
@click="setEditorMode('write')"
|
||||
>
|
||||
작성
|
||||
</button>
|
||||
@@ -964,7 +1062,7 @@ const handleKeydown = (event) => {
|
||||
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
||||
:class="activeMode === 'preview' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
||||
type="button"
|
||||
@click="activeMode = 'preview'"
|
||||
@click="setEditorMode('preview')"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
|
||||
@@ -99,6 +99,31 @@ const parseImageLine = (line) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
||||
* @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 === ':::embed' ||
|
||||
trimmedLine.startsWith(':::callout') ||
|
||||
trimmedLine.startsWith(':::toggle') ||
|
||||
trimmedLine.startsWith('```') ||
|
||||
trimmedLine === '---' ||
|
||||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
||||
trimmedLine.startsWith('> ') ||
|
||||
/^- /.test(trimmedLine) ||
|
||||
/^\d+\.\s+/.test(trimmedLine) ||
|
||||
Boolean(parseImageLine(trimmedLine))
|
||||
}
|
||||
|
||||
/**
|
||||
* 닫힘 표식까지의 행 목록을 반환
|
||||
* @param {Array<string>} lines - 전체 마크다운 행
|
||||
@@ -224,14 +249,7 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
const line = lines[index]
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
|
||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!trimmedLine) {
|
||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
|
||||
if (trimmedLine === BLANK_PARAGRAPH_MARKER || !trimmedLine) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
@@ -380,8 +398,22 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`))
|
||||
const paragraphLines = [trimmedLine]
|
||||
index += 1
|
||||
|
||||
while (index < lines.length) {
|
||||
const nextLine = lines[index]
|
||||
const nextTrimmedLine = nextLine.trim()
|
||||
|
||||
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
|
||||
break
|
||||
}
|
||||
|
||||
paragraphLines.push(nextTrimmedLine)
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
|
||||
}
|
||||
|
||||
return blocks
|
||||
@@ -447,6 +479,15 @@ const parseInlineSegments = (value) => {
|
||||
return segments.length ? segments : [{ type: 'text', text: source }]
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
|
||||
* @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 - 이미지 목록
|
||||
@@ -489,8 +530,7 @@ const showNextImage = () => {
|
||||
<template>
|
||||
<div class="content-markdown-renderer">
|
||||
<template v-for="block in blocks" :key="block.id">
|
||||
<div v-if="block.type === 'spacer'" class="content-markdown-renderer__spacer h-6" aria-hidden="true" />
|
||||
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
|
||||
<ProseHeading v-if="block.type === 'heading'" :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>
|
||||
@@ -576,13 +616,16 @@ const showNextImage = () => {
|
||||
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
|
||||
><code>{{ block.text }}</code></pre>
|
||||
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
||||
<p v-else class="content-markdown-renderer__paragraph my-5 text-[15px] leading-8 text-[var(--site-text)]">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-paragraph-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ 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>
|
||||
<p v-else class="content-markdown-renderer__paragraph mb-6 text-[15px] leading-8 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>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ 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>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user