관리자 블록형 글쓰기 추가
This commit is contained in:
541
components/admin/AdminBlockEditor.vue
Normal file
541
components/admin/AdminBlockEditor.vue
Normal file
@@ -0,0 +1,541 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const editorBlocks = ref([])
|
||||
const blockRefs = ref([])
|
||||
const activeBlockId = ref('')
|
||||
const slashQuery = ref('')
|
||||
const isApplyingExternalValue = ref(false)
|
||||
let blockIdSeed = 0
|
||||
|
||||
const blockCommands = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
label: '문단',
|
||||
description: '기본 본문 블록',
|
||||
keywords: ['p', 'text', 'paragraph', '문단']
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
level: 2,
|
||||
label: '제목 2',
|
||||
description: '섹션 제목',
|
||||
keywords: ['h2', 'heading', 'title', '제목']
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
level: 3,
|
||||
label: '제목 3',
|
||||
description: '작은 섹션 제목',
|
||||
keywords: ['h3', 'heading', 'subtitle', '제목']
|
||||
},
|
||||
{
|
||||
type: 'quote',
|
||||
label: '인용',
|
||||
description: '강조 인용문',
|
||||
keywords: ['quote', 'blockquote', '인용']
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
label: '목록',
|
||||
description: '불릿 목록',
|
||||
keywords: ['list', 'bullet', '목록']
|
||||
},
|
||||
{
|
||||
type: 'code',
|
||||
label: '코드',
|
||||
description: '코드 블록',
|
||||
keywords: ['code', 'pre', '코드']
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
label: '구분선',
|
||||
description: '본문 구간 분리',
|
||||
keywords: ['divider', 'hr', 'line', '구분선']
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 에디터 블록 생성
|
||||
* @param {string} type - 블록 타입
|
||||
* @param {string} text - 블록 텍스트
|
||||
* @param {number|null} level - 제목 레벨
|
||||
* @param {string} id - 블록 ID
|
||||
* @returns {{ id: string, type: string, text: string, level: number|null }} 에디터 블록
|
||||
*/
|
||||
const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '') => ({
|
||||
id: id || `editor-block-new-${blockIdSeed += 1}`,
|
||||
type,
|
||||
text,
|
||||
level
|
||||
})
|
||||
|
||||
/**
|
||||
* 저장된 마크다운 문자열을 에디터 블록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
* @returns {Array<Object>} 에디터 블록 목록
|
||||
*/
|
||||
const parseMarkdownToBlocks = (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) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const codeLines = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
||||
codeLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createEditorBlock('code', codeLines.join('\n'), null, `editor-block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === '---') {
|
||||
blocks.push(createEditorBlock('divider', '', null, `editor-block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)
|
||||
|
||||
if (headingMatch) {
|
||||
blocks.push(createEditorBlock('heading', headingMatch[2], headingMatch[1].length, `editor-block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('> ')) {
|
||||
blocks.push(createEditorBlock('quote', trimmedLine.replace(/^>\s?/, ''), null, `editor-block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^- /.test(trimmedLine)) {
|
||||
blocks.push(createEditorBlock('list', trimmedLine.replace(/^- /, ''), null, `editor-block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push(createEditorBlock('paragraph', trimmedLine, null, `editor-block-${blocks.length}`))
|
||||
index += 1
|
||||
}
|
||||
|
||||
return blocks.length ? blocks : [createEditorBlock('paragraph', '', null, 'editor-block-0')]
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디터 블록 목록을 저장용 마크다운으로 변환
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
const serializeBlocks = () => {
|
||||
const lines = editorBlocks.value
|
||||
.map((block) => {
|
||||
const text = block.text.trim()
|
||||
|
||||
if (block.type === 'divider') {
|
||||
return { type: block.type, value: '---' }
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (block.type === 'heading') {
|
||||
return { type: block.type, value: `${'#'.repeat(block.level || 2)} ${text}` }
|
||||
}
|
||||
|
||||
if (block.type === 'quote') {
|
||||
return { type: block.type, value: `> ${text}` }
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
return { type: block.type, value: `- ${text}` }
|
||||
}
|
||||
|
||||
if (block.type === 'code') {
|
||||
return { type: block.type, value: `\`\`\`\n${block.text}\n\`\`\`` }
|
||||
}
|
||||
|
||||
return { type: block.type, value: text }
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return lines.reduce((markdown, line, index) => {
|
||||
if (index === 0) {
|
||||
return line.value
|
||||
}
|
||||
|
||||
const previousLine = lines[index - 1]
|
||||
const joiner = previousLine.type === 'list' && line.type === 'list'
|
||||
? '\n'
|
||||
: '\n\n'
|
||||
|
||||
return `${markdown}${joiner}${line.value}`
|
||||
}, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모 폼으로 콘텐츠 변경 전달
|
||||
* @returns {void}
|
||||
*/
|
||||
const emitContent = () => {
|
||||
emit('update:modelValue', serializeBlocks())
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 DOM 요소를 저장
|
||||
* @param {Element|null} element - 블록 DOM 요소
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const setBlockRef = (element, index) => {
|
||||
if (element) {
|
||||
blockRefs.value[index] = element
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 블록으로 커서 이동
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusBlock = (index) => {
|
||||
nextTick(() => {
|
||||
const element = blockRefs.value[index]
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
element.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 타입에 맞는 태그명 반환
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @returns {string} HTML 태그명
|
||||
*/
|
||||
const getBlockTag = (block) => {
|
||||
if (block.type === 'heading') {
|
||||
return `h${block.level || 2}`
|
||||
}
|
||||
|
||||
if (block.type === 'quote') {
|
||||
return 'blockquote'
|
||||
}
|
||||
|
||||
if (block.type === 'code') {
|
||||
return 'pre'
|
||||
}
|
||||
|
||||
return 'div'
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 타입에 맞는 클래스 반환
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @returns {Array<string|Object>} 클래스 목록
|
||||
*/
|
||||
const getBlockClass = (block) => [
|
||||
'admin-block-editor__block outline-none transition-colors',
|
||||
{
|
||||
'admin-block-editor__paragraph min-h-8 text-[17px] leading-8': block.type === 'paragraph',
|
||||
'admin-block-editor__heading mt-8 min-h-10 font-semibold leading-tight': block.type === 'heading',
|
||||
'admin-block-editor__heading--h1 text-5xl': block.type === 'heading' && block.level === 1,
|
||||
'admin-block-editor__heading--h2 text-4xl': block.type === 'heading' && block.level === 2,
|
||||
'admin-block-editor__heading--h3 text-3xl': block.type === 'heading' && block.level === 3,
|
||||
'admin-block-editor__quote my-5 border-l-4 border-ink bg-surface px-5 py-3 text-xl font-medium leading-8': block.type === 'quote',
|
||||
'admin-block-editor__list relative min-h-8 pl-7 text-[17px] leading-8 before:absolute before:left-2 before:top-3 before:h-2 before:w-2 before:rounded-full before:bg-current': block.type === 'list',
|
||||
'admin-block-editor__code my-5 min-h-14 whitespace-pre-wrap rounded bg-[#15171a] px-4 py-3 font-mono text-sm leading-6 text-white': block.type === 'code'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 블록 텍스트 입력 처리
|
||||
* @param {InputEvent} event - 입력 이벤트
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateBlockText = (event, index) => {
|
||||
const block = editorBlocks.value[index]
|
||||
const text = event.target.innerText.replace(/\n$/, '')
|
||||
|
||||
block.text = text
|
||||
activeBlockId.value = block.id
|
||||
applyMarkdownShortcut(block, index)
|
||||
updateSlashQuery(block)
|
||||
emitContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 입력 단축 문법 적용
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyMarkdownShortcut = (block, index) => {
|
||||
const shortcutMap = [
|
||||
{ marker: '# ', type: 'heading', level: 1 },
|
||||
{ marker: '## ', type: 'heading', level: 2 },
|
||||
{ marker: '### ', type: 'heading', level: 3 },
|
||||
{ marker: '> ', type: 'quote' },
|
||||
{ marker: '- ', type: 'list' },
|
||||
{ marker: '``` ', type: 'code' }
|
||||
].sort((a, b) => b.marker.length - a.marker.length)
|
||||
|
||||
const shortcut = shortcutMap.find((item) => block.text.startsWith(item.marker))
|
||||
|
||||
if (!shortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
block.type = shortcut.type
|
||||
block.level = shortcut.level || null
|
||||
block.text = block.text.slice(shortcut.marker.length)
|
||||
slashQuery.value = ''
|
||||
|
||||
nextTick(() => {
|
||||
const element = blockRefs.value[index]
|
||||
|
||||
if (element) {
|
||||
element.innerText = block.text
|
||||
focusBlock(index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 메뉴 검색어 갱신
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateSlashQuery = (block) => {
|
||||
slashQuery.value = block.text.startsWith('/')
|
||||
? block.text.slice(1).trim().toLowerCase()
|
||||
: ''
|
||||
}
|
||||
|
||||
const activeBlockIndex = computed(() => editorBlocks.value.findIndex((block) => block.id === activeBlockId.value))
|
||||
|
||||
const visibleCommands = computed(() => {
|
||||
if (activeBlockIndex.value < 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const block = editorBlocks.value[activeBlockIndex.value]
|
||||
|
||||
if (!block?.text.startsWith('/')) {
|
||||
return []
|
||||
}
|
||||
|
||||
return blockCommands.filter((command) => [
|
||||
command.label,
|
||||
command.description,
|
||||
...command.keywords
|
||||
].some((keyword) => keyword.toLowerCase().includes(slashQuery.value)))
|
||||
})
|
||||
|
||||
/**
|
||||
* 슬래시 메뉴 명령 적용
|
||||
* @param {Object} command - 블록 명령
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyCommand = (command) => {
|
||||
const index = activeBlockIndex.value
|
||||
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const block = editorBlocks.value[index]
|
||||
block.type = command.type
|
||||
block.level = command.level || null
|
||||
block.text = ''
|
||||
slashQuery.value = ''
|
||||
|
||||
if (command.type === 'divider') {
|
||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||
emitContent()
|
||||
focusBlock(index + 1)
|
||||
return
|
||||
}
|
||||
|
||||
emitContent()
|
||||
focusBlock(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔터 키로 다음 블록 생성
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleEnter = (event, index) => {
|
||||
const currentBlock = editorBlocks.value[index]
|
||||
|
||||
if (currentBlock.type === 'code' && !event.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (currentBlock.type === 'divider') {
|
||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||
emitContent()
|
||||
focusBlock(index + 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
|
||||
currentBlock.type = 'paragraph'
|
||||
currentBlock.level = null
|
||||
emitContent()
|
||||
focusBlock(index)
|
||||
return
|
||||
}
|
||||
|
||||
const nextType = currentBlock.type === 'list' ? 'list' : 'paragraph'
|
||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock(nextType))
|
||||
emitContent()
|
||||
focusBlock(index + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 백스페이스 키로 빈 블록 삭제
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBackspace = (event, index) => {
|
||||
const block = editorBlocks.value[index]
|
||||
|
||||
if (block.text || editorBlocks.value.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
editorBlocks.value.splice(index, 1)
|
||||
emitContent()
|
||||
focusBlock(Math.max(index - 1, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 블록 활성화
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const activateBlock = (block) => {
|
||||
activeBlockId.value = block.id
|
||||
updateSlashQuery(block)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
if (isApplyingExternalValue.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentValue = serializeBlocks()
|
||||
|
||||
if (value === currentValue) {
|
||||
return
|
||||
}
|
||||
|
||||
editorBlocks.value = parseMarkdownToBlocks(value)
|
||||
}, { immediate: true })
|
||||
|
||||
watch(editorBlocks, () => {
|
||||
isApplyingExternalValue.value = true
|
||||
nextTick(() => {
|
||||
isApplyingExternalValue.value = false
|
||||
})
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-block-editor rounded border border-line bg-white px-5 py-5">
|
||||
<div class="admin-block-editor__surface post-prose grid gap-1">
|
||||
<div
|
||||
v-for="(block, index) in editorBlocks"
|
||||
:key="block.id"
|
||||
class="admin-block-editor__row relative"
|
||||
>
|
||||
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
|
||||
<component
|
||||
:is="getBlockTag(block)"
|
||||
v-else
|
||||
:ref="(element) => setBlockRef(element, index)"
|
||||
:class="getBlockClass(block)"
|
||||
contenteditable="true"
|
||||
spellcheck="true"
|
||||
:data-placeholder="index === 0 ? '본문을 입력하거나 / 를 눌러 블록을 선택하세요' : '/ 를 눌러 블록 선택'"
|
||||
@focus="activateBlock(block)"
|
||||
@input="updateBlockText($event, index)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
{{ block.text }}
|
||||
</component>
|
||||
|
||||
<button
|
||||
v-if="block.type === 'divider'"
|
||||
class="admin-block-editor__divider-button w-full rounded py-2 text-left text-xs font-semibold text-muted"
|
||||
type="button"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
>
|
||||
구분선
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="visibleCommands.length && activeBlockId === block.id"
|
||||
class="admin-block-editor__slash-menu absolute left-0 top-full z-20 mt-2 w-72 overflow-hidden rounded border border-line bg-white shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="command in visibleCommands"
|
||||
:key="`${command.type}-${command.level || 'default'}`"
|
||||
class="admin-block-editor__slash-item grid w-full gap-0.5 px-4 py-3 text-left hover:bg-surface"
|
||||
type="button"
|
||||
@mousedown.prevent="applyCommand(command)"
|
||||
>
|
||||
<span class="admin-block-editor__slash-label text-sm font-semibold">{{ command.label }}</span>
|
||||
<span class="admin-block-editor__slash-description text-xs text-muted">{{ command.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-block-editor__block:empty::before {
|
||||
color: var(--site-soft);
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -1,152 +0,0 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* HTML 특수 문자 이스케이프
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 이스케이프된 문자열
|
||||
*/
|
||||
const escapeHtml = (value) => value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
/**
|
||||
* 인라인 마크다운 변환
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 변환된 HTML
|
||||
*/
|
||||
const parseInlineMarkdown = (value) => escapeHtml(value)
|
||||
.replace(/`([^`]+)`/g, '<code class="admin-markdown-preview__inline-code">$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
|
||||
/**
|
||||
* 목록 HTML 변환
|
||||
* @param {Array<string>} items - 목록 아이템
|
||||
* @param {'ul' | 'ol'} tagName - 목록 태그명
|
||||
* @returns {string} 목록 HTML
|
||||
*/
|
||||
const renderList = (items, tagName) => {
|
||||
const listItems = items
|
||||
.map((item) => `<li>${parseInlineMarkdown(item)}</li>`)
|
||||
.join('')
|
||||
|
||||
return `<${tagName}>${listItems}</${tagName}>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 마크다운을 미리보기 HTML로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
* @returns {string} 미리보기 HTML
|
||||
*/
|
||||
const renderMarkdown = (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) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const codeLines = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
||||
codeLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)
|
||||
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length
|
||||
blocks.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('> ')) {
|
||||
const quotes = []
|
||||
|
||||
while (index < lines.length && lines[index].trim().startsWith('> ')) {
|
||||
quotes.push(lines[index].trim().replace(/^>\s?/, ''))
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(`<blockquote>${quotes.map((quote) => `<p>${parseInlineMarkdown(quote)}</p>`).join('')}</blockquote>`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^- /.test(trimmedLine)) {
|
||||
const items = []
|
||||
|
||||
while (index < lines.length && /^- /.test(lines[index].trim())) {
|
||||
items.push(lines[index].trim().replace(/^- /, ''))
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(renderList(items, 'ul'))
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d+\. /.test(trimmedLine)) {
|
||||
const items = []
|
||||
|
||||
while (index < lines.length && /^\d+\. /.test(lines[index].trim())) {
|
||||
items.push(lines[index].trim().replace(/^\d+\. /, ''))
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(renderList(items, 'ol'))
|
||||
continue
|
||||
}
|
||||
|
||||
const paragraphs = []
|
||||
|
||||
while (
|
||||
index < lines.length &&
|
||||
lines[index].trim() &&
|
||||
!lines[index].trim().startsWith('```') &&
|
||||
!lines[index].trim().match(/^(#{1,3})\s+(.+)$/) &&
|
||||
!lines[index].trim().startsWith('> ') &&
|
||||
!/^- /.test(lines[index].trim()) &&
|
||||
!/^\d+\. /.test(lines[index].trim())
|
||||
) {
|
||||
paragraphs.push(lines[index].trim())
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(`<p>${parseInlineMarkdown(paragraphs.join(' '))}</p>`)
|
||||
}
|
||||
|
||||
return blocks.join('')
|
||||
}
|
||||
|
||||
const previewHtml = computed(() => renderMarkdown(props.content))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="admin-markdown-preview post-prose min-h-[28rem] rounded border border-line bg-white px-5 py-4 text-sm leading-7"
|
||||
v-html="previewHtml"
|
||||
/>
|
||||
</template>
|
||||
@@ -17,8 +17,6 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const slugTouched = ref(Boolean(props.initialPost.slug))
|
||||
const editorMode = ref('write')
|
||||
const contentTextarea = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
title: props.initialPost.title || '',
|
||||
@@ -68,77 +66,6 @@ const parseTags = (value) => [...new Set(value
|
||||
.map((tag) => toSlug(tag))
|
||||
.filter(Boolean))]
|
||||
|
||||
/**
|
||||
* 본문 선택 영역에 마크다운 문법 삽입
|
||||
* @param {string} before - 선택 영역 앞에 넣을 문자열
|
||||
* @param {string} after - 선택 영역 뒤에 넣을 문자열
|
||||
* @param {string} fallback - 선택 영역이 없을 때 넣을 문자열
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertMarkdown = (before, after = '', fallback = '') => {
|
||||
const textarea = contentTextarea.value
|
||||
|
||||
if (!textarea) {
|
||||
form.content += fallback || before
|
||||
return
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selectedText = form.content.slice(start, end)
|
||||
const insertText = selectedText
|
||||
? `${before}${selectedText}${after}`
|
||||
: (fallback || `${before}${after}`)
|
||||
|
||||
form.content = `${form.content.slice(0, start)}${insertText}${form.content.slice(end)}`
|
||||
|
||||
nextTick(() => {
|
||||
textarea.focus()
|
||||
const cursor = start + insertText.length
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 문법 삽입
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertHeading = () => {
|
||||
insertMarkdown('## ', '', '## 제목')
|
||||
}
|
||||
|
||||
/**
|
||||
* 굵게 문법 삽입
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertBold = () => {
|
||||
insertMarkdown('**', '**', '**강조**')
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 문법 삽입
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertList = () => {
|
||||
insertMarkdown('- ', '', '- 목록')
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 문법 삽입
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertQuote = () => {
|
||||
insertMarkdown('> ', '', '> 인용')
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 블록 문법 삽입
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertCodeBlock = () => {
|
||||
insertMarkdown('```\n', '\n```', '```\n코드\n```')
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 입력값 제출
|
||||
* @returns {void}
|
||||
@@ -178,53 +105,10 @@ const submitPost = () => {
|
||||
<div class="admin-post-form__field grid gap-2 text-sm">
|
||||
<div class="admin-post-form__editor-header flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="admin-post-form__label font-medium">본문</span>
|
||||
<div class="admin-post-form__mode flex rounded border border-line bg-white p-1 text-xs font-semibold">
|
||||
<button
|
||||
class="admin-post-form__mode-button rounded px-3 py-1"
|
||||
:class="editorMode === 'write' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
type="button"
|
||||
@click="editorMode = 'write'"
|
||||
>
|
||||
작성
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__mode-button rounded px-3 py-1"
|
||||
:class="editorMode === 'preview' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
type="button"
|
||||
@click="editorMode = 'preview'"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
</div>
|
||||
<span class="admin-post-form__editor-note text-xs text-muted">/ 명령과 마크다운 단축 입력 지원</span>
|
||||
</div>
|
||||
|
||||
<div v-if="editorMode === 'write'" class="admin-post-form__editor grid gap-2">
|
||||
<div class="admin-post-form__toolbar flex flex-wrap gap-2">
|
||||
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertHeading">
|
||||
제목
|
||||
</button>
|
||||
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertBold">
|
||||
굵게
|
||||
</button>
|
||||
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertList">
|
||||
목록
|
||||
</button>
|
||||
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertQuote">
|
||||
인용
|
||||
</button>
|
||||
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertCodeBlock">
|
||||
코드
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref="contentTextarea"
|
||||
v-model="form.content"
|
||||
class="admin-post-form__textarea min-h-[28rem] rounded border border-line bg-white px-3 py-3 font-mono text-sm leading-6"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminMarkdownPreview v-else :content="form.content" />
|
||||
<AdminBlockEditor v-model="form.content" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user