Files
sori.studio/components/admin/AdminBlockEditor.vue

593 lines
16 KiB
Vue

<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 slashMenuDirection = ref('down')
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
if (element.innerText !== editorBlocks.value[index]?.text) {
element.innerText = editorBlocks.value[index]?.text || ''
}
}
}
/**
* 지정 블록으로 커서 이동
* @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 {number} index - 블록 인덱스
* @returns {void}
*/
const updateSlashMenuDirection = (index) => {
nextTick(() => {
const element = blockRefs.value[index]
if (!element) {
slashMenuDirection.value = 'down'
return
}
const rect = element.getBoundingClientRect()
const menuHeight = 280
slashMenuDirection.value = window.innerHeight - rect.bottom < menuHeight && rect.top > menuHeight
? 'up'
: 'down'
})
}
/**
* 블록 타입에 맞는 태그명 반환
* @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)
updateSlashMenuDirection(index)
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 = ''
const element = blockRefs.value[index]
if (element) {
element.innerText = ''
}
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.text.trim() && currentBlock.type === 'paragraph') {
return
}
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) => {
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
activeBlockId.value = block.id
updateSlashQuery(block)
updateSlashMenuDirection(index)
}
/**
* 블록 placeholder 표시 여부 반환
* @param {Object} block - 에디터 블록
* @param {number} index - 블록 인덱스
* @returns {boolean} placeholder 표시 여부
*/
const shouldShowPlaceholder = (block, index) => !block.text && (
activeBlockId.value === block.id ||
(index === 0 && editorBlocks.value.length === 1)
)
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 min-h-[32rem] bg-transparent py-4">
<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 ? '본문을 입력하거나 / 눌러 블록을 선택하세요' : '/ 눌러 블록 선택'"
:data-show-placeholder="shouldShowPlaceholder(block, index)"
@focus="activateBlock(block)"
@input="updateBlockText($event, index)"
@keydown.enter="handleEnter($event, index)"
@keydown.backspace="handleBackspace($event, index)"
/>
<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 z-20 w-72 overflow-hidden rounded border border-line bg-white shadow-lg"
:class="slashMenuDirection === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'"
>
<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[data-show-placeholder="true"]::before {
color: var(--site-soft);
content: attr(data-placeholder);
}
</style>