3130 lines
92 KiB
Vue
3130 lines
92 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 selectedBlockId = ref('')
|
|
const draggingBlockId = ref('')
|
|
const dragTargetIndex = ref(-1)
|
|
const dragTargetPosition = ref('')
|
|
const slashQuery = ref('')
|
|
const slashMenuDirection = ref('down')
|
|
const highlightedCommandIndex = ref(0)
|
|
const isApplyingExternalValue = ref(false)
|
|
const uploadingBlockIds = ref([])
|
|
const mediaItems = ref([])
|
|
const mediaPickerTarget = ref(null)
|
|
const selectedGalleryMediaUrls = ref([])
|
|
const isMediaPickerOpen = ref(false)
|
|
const isLoadingMedia = ref(false)
|
|
const isComposingText = ref(false)
|
|
const isNormalizingTrailingBlock = ref(false)
|
|
const pendingSoftLineBreakIndex = ref(-1)
|
|
const pendingSlashCommandIndex = ref(-1)
|
|
const draggingGalleryImage = ref(null)
|
|
const galleryDragTarget = ref(null)
|
|
const isKeyboardPriorityMode = ref(false)
|
|
const calloutEmojiPickerBlockId = ref('')
|
|
const calloutColorPopoverBlockId = ref('')
|
|
const calloutEmojiComposingBlockId = ref('')
|
|
const editorFlashMessage = ref('')
|
|
const blockRangeSelection = ref(null)
|
|
const isBlockRangeDragging = ref(false)
|
|
const blockEditorRootRef = ref(null)
|
|
let blockIdSeed = 0
|
|
let editorFlashTimer = null
|
|
|
|
const imageWidthOptions = [
|
|
{ value: 'regular', label: '기본' },
|
|
{ value: 'wide', label: '와이드' },
|
|
{ value: 'full', label: '풀사이즈' }
|
|
]
|
|
|
|
const calloutBackgroundOptions = [
|
|
{ value: 'gray', label: '회색' },
|
|
{ value: 'blue', label: '파랑' },
|
|
{ value: 'green', label: '초록' },
|
|
{ value: 'yellow', label: '노랑' },
|
|
{ value: 'red', label: '빨강' },
|
|
{ value: 'purple', label: '보라' },
|
|
{ value: 'pink', label: '핑크' }
|
|
]
|
|
|
|
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: 'image',
|
|
label: '이미지',
|
|
description: '단일 이미지 업로드',
|
|
keywords: ['image', 'photo', '이미지', '사진']
|
|
},
|
|
{
|
|
type: 'gallery',
|
|
label: '갤러리',
|
|
description: '여러 이미지 업로드',
|
|
keywords: ['gallery', 'images', '갤러리', '사진']
|
|
},
|
|
{
|
|
type: 'callout',
|
|
label: '콜아웃',
|
|
description: '강조 안내 블록',
|
|
keywords: ['callout', 'notice', 'info', '콜아웃', '안내']
|
|
},
|
|
{
|
|
type: 'toggle',
|
|
label: '토글',
|
|
description: '접고 펼치는 본문 블록',
|
|
keywords: ['toggle', 'details', '토글', '접기']
|
|
},
|
|
{
|
|
type: 'embed',
|
|
label: '임베드',
|
|
description: 'YouTube 등 외부 링크 삽입',
|
|
keywords: ['embed', 'youtube', 'link', '임베드', '유튜브']
|
|
},
|
|
{
|
|
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', '구분선']
|
|
}
|
|
]
|
|
|
|
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
|
|
|
/**
|
|
* 에디터 블록 생성
|
|
* @param {string} type - 블록 타입
|
|
* @param {string} text - 블록 텍스트
|
|
* @param {number|null} level - 제목 레벨
|
|
* @param {string} id - 블록 ID
|
|
* @param {Object} options - 추가 블록 옵션
|
|
* @returns {Object} 에디터 블록
|
|
*/
|
|
const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
|
id: id || `editor-block-new-${blockIdSeed += 1}`,
|
|
type,
|
|
text,
|
|
level,
|
|
url: options.url || '',
|
|
alt: options.alt || '',
|
|
title: options.title || '',
|
|
width: options.width || 'regular',
|
|
images: options.images || [],
|
|
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
|
|
calloutEmoji: options.calloutEmoji || '💡',
|
|
calloutBackground: options.calloutBackground || 'blue'
|
|
})
|
|
|
|
/**
|
|
* 콜아웃 선언부 옵션을 파싱
|
|
* @param {string} line - 콜아웃 선언 라인
|
|
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
|
|
*/
|
|
const parseCalloutOptions = (line) => {
|
|
const options = {
|
|
calloutEmojiEnabled: true,
|
|
calloutEmoji: '💡',
|
|
calloutBackground: 'blue'
|
|
}
|
|
const tokens = line.trim().split(/\s+/).slice(1)
|
|
|
|
tokens.forEach((token) => {
|
|
const [rawKey, ...rawValueParts] = token.split('=')
|
|
if (!rawKey || !rawValueParts.length) {
|
|
return
|
|
}
|
|
const key = rawKey.toLowerCase()
|
|
const value = rawValueParts.join('=').trim()
|
|
|
|
if (key === 'emoji') {
|
|
if (!value || value === 'none') {
|
|
options.calloutEmojiEnabled = false
|
|
options.calloutEmoji = '💡'
|
|
} else {
|
|
options.calloutEmojiEnabled = true
|
|
options.calloutEmoji = value
|
|
}
|
|
return
|
|
}
|
|
|
|
if (key === 'bg' && calloutBackgroundOptions.some((item) => item.value === value)) {
|
|
options.calloutBackground = value
|
|
}
|
|
})
|
|
|
|
return options
|
|
}
|
|
|
|
/**
|
|
* 이미지 마크다운 행을 블록 옵션으로 변환
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {Object|null} 이미지 블록 옵션
|
|
*/
|
|
const parseImageLine = (line) => {
|
|
const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)
|
|
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
alt: match[1],
|
|
url: match[2],
|
|
width: match[3] || 'regular'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 닫힘 표식까지의 행 목록을 반환
|
|
* @param {Array<string>} lines - 전체 마크다운 행
|
|
* @param {number} startIndex - 본문 시작 인덱스
|
|
* @returns {{contentLines: Array<string>, 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,
|
|
nextIndex: index + 1
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 저장된 마크다운 문자열을 에디터 블록으로 변환
|
|
* @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 === BLANK_PARAGRAPH_MARKER) {
|
|
blocks.push(createEditorBlock('paragraph', '', null, `editor-block-${blocks.length}`))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (!trimmedLine) {
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::gallery') {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const images = []
|
|
|
|
contentLines.forEach((contentLine) => {
|
|
const image = parseImageLine(contentLine)
|
|
if (image) {
|
|
images.push(image)
|
|
}
|
|
})
|
|
|
|
blocks.push(createEditorBlock('gallery', '', null, `editor-block-${blocks.length}`, { images }))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith(':::callout')) {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
blocks.push(createEditorBlock('callout', contentLines.join('\n'), null, `editor-block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith(':::toggle')) {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
|
|
blocks.push(createEditorBlock('toggle', contentLines.join('\n'), null, `editor-block-${blocks.length}`, { title }))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::embed') {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
blocks.push(createEditorBlock('embed', '', null, `editor-block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
const image = parseImageLine(trimmedLine)
|
|
|
|
if (image) {
|
|
blocks.push(createEditorBlock('image', '', null, `editor-block-${blocks.length}`, image))
|
|
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')]
|
|
}
|
|
|
|
/**
|
|
* 이미지 블록을 마크다운 문자열로 변환
|
|
* @param {Object} image - 이미지 데이터
|
|
* @returns {string} 이미지 마크다운
|
|
*/
|
|
const serializeImage = (image) => {
|
|
const width = image.width && image.width !== 'regular'
|
|
? `{width=${image.width}}`
|
|
: ''
|
|
|
|
return `${width}`
|
|
}
|
|
|
|
/**
|
|
* 주어진 블록 배열을 저장용 마크다운으로 변환한다.
|
|
* @param {Array<Object>} blocks - 직렬화할 블록 목록
|
|
* @returns {string} 마크다운 문자열
|
|
*/
|
|
const serializeBlockArray = (blocks) => {
|
|
const lines = blocks
|
|
.map((block, index) => {
|
|
const rawText = block.text || ''
|
|
const text = rawText.trim()
|
|
|
|
if (block.type === 'divider') {
|
|
return { type: block.type, value: '---' }
|
|
}
|
|
|
|
if (block.type === 'image') {
|
|
return block.url
|
|
? { type: block.type, value: serializeImage(block) }
|
|
: null
|
|
}
|
|
|
|
if (block.type === 'gallery') {
|
|
const images = block.images.filter((image) => image.url)
|
|
|
|
if (!images.length) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
type: block.type,
|
|
value: [':::gallery', ...images.map((image) => serializeImage(image)), ':::'].join('\n')
|
|
}
|
|
}
|
|
|
|
if (block.type === 'callout') {
|
|
const emoji = block.calloutEmojiEnabled
|
|
? (block.calloutEmoji || '💡')
|
|
: 'none'
|
|
const bg = calloutBackgroundOptions.some((item) => item.value === block.calloutBackground)
|
|
? block.calloutBackground
|
|
: 'blue'
|
|
return text
|
|
? { type: block.type, value: `:::callout emoji=${emoji} bg=${bg}\n${text}\n:::` }
|
|
: null
|
|
}
|
|
|
|
if (block.type === 'toggle') {
|
|
const title = block.title.trim()
|
|
return title || text
|
|
? { type: block.type, value: `:::toggle ${title || '더 보기'}\n${text}\n:::` }
|
|
: null
|
|
}
|
|
|
|
if (block.type === 'embed') {
|
|
return block.url.trim()
|
|
? { type: block.type, value: `:::embed\n${block.url.trim()}\n:::` }
|
|
: null
|
|
}
|
|
|
|
if (!text && block.type === 'paragraph') {
|
|
if (index === blocks.length - 1) {
|
|
return null
|
|
}
|
|
|
|
return { type: block.type, value: BLANK_PARAGRAPH_MARKER }
|
|
}
|
|
|
|
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}`
|
|
}, '')
|
|
}
|
|
|
|
/**
|
|
* 에디터 블록 인덱스 구간을 마크다운으로 직렬화한다.
|
|
* @param {number} startIdx - 시작 인덱스
|
|
* @param {number} endIdx - 끝 인덱스(포함)
|
|
* @returns {string} 마크다운 문자열
|
|
*/
|
|
const serializeBlockIndexSlice = (startIdx, endIdx) => {
|
|
const lo = Math.max(0, Math.min(startIdx, endIdx))
|
|
const hi = Math.min(editorBlocks.value.length - 1, Math.max(startIdx, endIdx))
|
|
|
|
if (lo > hi) {
|
|
return ''
|
|
}
|
|
|
|
return serializeBlockArray(editorBlocks.value.slice(lo, hi + 1))
|
|
}
|
|
|
|
/**
|
|
* 에디터 블록 목록을 저장용 마크다운으로 변환
|
|
* @returns {string} 마크다운 문자열
|
|
*/
|
|
const serializeBlocks = () => serializeBlockArray(editorBlocks.value)
|
|
|
|
/**
|
|
* 부모 폼으로 콘텐츠 변경 전달
|
|
* @returns {void}
|
|
*/
|
|
const emitContent = () => {
|
|
emit('update:modelValue', serializeBlocks())
|
|
}
|
|
|
|
/**
|
|
* 키보드 입력 우선 모드 활성화
|
|
* @returns {void}
|
|
*/
|
|
const enableKeyboardPriorityMode = () => {
|
|
isKeyboardPriorityMode.value = true
|
|
}
|
|
|
|
/**
|
|
* 마우스 상호작용 시 hover 복귀
|
|
* @param {MouseEvent} event - 마우스 이동 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const handleEditorMouseMove = (event) => {
|
|
if (event.buttons !== 0) {
|
|
return
|
|
}
|
|
|
|
isKeyboardPriorityMode.value = false
|
|
}
|
|
|
|
/**
|
|
* 텍스트 입력 블록 여부 반환
|
|
* @param {Object} block - 에디터 블록
|
|
* @returns {boolean} 텍스트 입력 블록 여부
|
|
*/
|
|
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
|
|
|
|
/**
|
|
* 갤러리 열 개수 반환
|
|
* @param {Object} block - 갤러리 블록
|
|
* @returns {number} 열 개수
|
|
*/
|
|
const getGalleryColumnCount = (block) => Math.min(Math.max(block.images.length, 1), 3)
|
|
|
|
/**
|
|
* 갤러리 미디어 선택 여부 확인
|
|
* @param {Object} mediaItem - 미디어 항목
|
|
* @returns {boolean} 선택 여부
|
|
*/
|
|
const isGalleryMediaSelected = (mediaItem) => selectedGalleryMediaUrls.value.includes(mediaItem.url)
|
|
|
|
/**
|
|
* 비어 있는 문단 블록 여부 반환
|
|
* @param {Object|undefined} block - 에디터 블록
|
|
* @returns {boolean} 비어 있는 문단 블록 여부
|
|
*/
|
|
const isBlankParagraphBlock = (block) => block?.type === 'paragraph' && !block.text
|
|
|
|
/**
|
|
* 마지막 클릭 가능 문단 블록 유지
|
|
* @returns {void}
|
|
*/
|
|
const normalizeTrailingTextBlock = () => {
|
|
if (isNormalizingTrailingBlock.value) {
|
|
return
|
|
}
|
|
|
|
isNormalizingTrailingBlock.value = true
|
|
|
|
if (!isBlankParagraphBlock(editorBlocks.value.at(-1))) {
|
|
editorBlocks.value.push(createEditorBlock('paragraph'))
|
|
}
|
|
|
|
nextTick(() => {
|
|
isNormalizingTrailingBlock.value = false
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 블록 DOM 요소를 저장
|
|
* @param {Element|null} element - 블록 DOM 요소
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const setBlockRef = (element, index) => {
|
|
if (element) {
|
|
blockRefs.value[index] = element
|
|
|
|
if (
|
|
!isComposingText.value
|
|
&& isTextBlock(editorBlocks.value[index])
|
|
&& element.textContent !== editorBlocks.value[index]?.text
|
|
) {
|
|
element.textContent = editorBlocks.value[index]?.text || ''
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정 블록으로 커서 이동
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const focusBlock = (index, position = 'end') => {
|
|
nextTick(() => {
|
|
const element = blockRefs.value[index]
|
|
|
|
if (!element) {
|
|
return
|
|
}
|
|
|
|
element.focus()
|
|
const selection = window.getSelection()
|
|
const range = document.createRange()
|
|
range.selectNodeContents(element)
|
|
range.collapse(position === 'start')
|
|
selection.removeAllRanges()
|
|
selection.addRange(range)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 현재 커서가 블록 시작/끝 경계에 있는지 확인
|
|
* @param {Element} element - 블록 요소
|
|
* @param {'start'|'end'} boundary - 경계 방향
|
|
* @returns {boolean} 경계 위치 여부
|
|
*/
|
|
const isCaretOnBoundary = (element, boundary) => {
|
|
const selection = window.getSelection()
|
|
if (!selection?.rangeCount) {
|
|
return false
|
|
}
|
|
|
|
const range = selection.getRangeAt(0)
|
|
if (!range.collapsed || !element.contains(range.commonAncestorContainer)) {
|
|
return false
|
|
}
|
|
|
|
const probeRange = range.cloneRange()
|
|
probeRange.selectNodeContents(element)
|
|
|
|
if (boundary === 'start') {
|
|
probeRange.setEnd(range.startContainer, range.startOffset)
|
|
return probeRange.toString().length === 0
|
|
}
|
|
|
|
probeRange.setStart(range.startContainer, range.startOffset)
|
|
return probeRange.toString().length === 0
|
|
}
|
|
|
|
/**
|
|
* 구조형 블록의 첫 입력 필드로 커서 이동
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const focusStructuredBlock = (index) => {
|
|
nextTick(() => {
|
|
const blockId = editorBlocks.value[index]?.id
|
|
const row = document.querySelector(`[data-editor-block-id="${blockId}"]`)
|
|
const field = row?.querySelector('input, textarea, button')
|
|
|
|
if (field) {
|
|
field.focus()
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 표시 방향 갱신
|
|
* @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'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 텍스트 블록 DOM의 현재 텍스트 반환
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {string} 현재 텍스트
|
|
*/
|
|
const getTextBlockDomText = (index) => {
|
|
const element = blockRefs.value[index]
|
|
|
|
return element?.textContent || ''
|
|
}
|
|
|
|
/**
|
|
* 현재 선택 영역에 문단 내부 줄바꿈을 삽입
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const insertSoftLineBreak = (index) => {
|
|
const block = editorBlocks.value[index]
|
|
const element = blockRefs.value[index]
|
|
|
|
if (!block || !element) {
|
|
return
|
|
}
|
|
|
|
element.focus()
|
|
const selection = window.getSelection()
|
|
|
|
if (!selection) {
|
|
return
|
|
}
|
|
|
|
const range = selection?.rangeCount ? selection.getRangeAt(0) : document.createRange()
|
|
|
|
if (!selection.rangeCount || !element.contains(range.commonAncestorContainer)) {
|
|
range.selectNodeContents(element)
|
|
range.collapse(false)
|
|
}
|
|
|
|
range.deleteContents()
|
|
const lineBreak = document.createTextNode('\n')
|
|
range.insertNode(lineBreak)
|
|
range.setStartAfter(lineBreak)
|
|
range.collapse(true)
|
|
selection.removeAllRanges()
|
|
selection.addRange(range)
|
|
|
|
block.text = getTextBlockDomText(index)
|
|
activeBlockId.value = block.id
|
|
slashQuery.value = ''
|
|
updateSlashMenuDirection(index)
|
|
emitContent()
|
|
}
|
|
|
|
/**
|
|
* 텍스트 블록 DOM 값을 상태에 즉시 반영
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {Object|undefined} 갱신한 블록
|
|
*/
|
|
const syncTextBlockFromDom = (index) => {
|
|
const block = editorBlocks.value[index]
|
|
|
|
if (!block || !isTextBlock(block)) {
|
|
return block
|
|
}
|
|
|
|
block.text = getTextBlockDomText(index)
|
|
activeBlockId.value = block.id
|
|
updateSlashQuery(block)
|
|
updateSlashMenuDirection(index)
|
|
|
|
return block
|
|
}
|
|
|
|
/**
|
|
* 에디터 상단에 잠깐 안내 문구를 표시한다.
|
|
* @param {string} message - 표시할 문구
|
|
* @returns {void}
|
|
*/
|
|
const flashEditorMessage = (message) => {
|
|
editorFlashMessage.value = message
|
|
|
|
if (import.meta.client && editorFlashTimer) {
|
|
window.clearTimeout(editorFlashTimer)
|
|
}
|
|
|
|
if (import.meta.client) {
|
|
editorFlashTimer = window.setTimeout(() => {
|
|
editorFlashMessage.value = ''
|
|
editorFlashTimer = null
|
|
}, 2600)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 클립보드 텍스트를 여러 블록으로 나누어 붙일지 판별한다.
|
|
* @param {string} text - 순수 텍스트
|
|
* @param {FileList|undefined} files - 첨부 파일
|
|
* @returns {boolean} 구조화 붙여넣기 여부
|
|
*/
|
|
const shouldParseClipboardAsBlocks = (text, files) => {
|
|
if (files && files.length > 0) {
|
|
return false
|
|
}
|
|
|
|
if (!text || !String(text).trim()) {
|
|
return false
|
|
}
|
|
|
|
if (text.includes('\n')) {
|
|
return true
|
|
}
|
|
|
|
const head = text.trimStart().slice(0, 120)
|
|
|
|
if (/^#{1,3}\s/m.test(head) || /^>\s/m.test(head) || /^- /m.test(head) || /^```/m.test(head) || /^:::+/m.test(head)) {
|
|
return true
|
|
}
|
|
|
|
if (/\[[^\]]+\]\([^)]+\)/.test(head)) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* contenteditable 요소 안에서 선택 구간의 순수 텍스트 기준 오프셋을 계산한다.
|
|
* @param {HTMLElement} element - 편집 루트 요소
|
|
* @returns {{ start: number, end: number }} 시작·끝 오프셋
|
|
*/
|
|
const getPlainTextOffsetsWithinElement = (element) => {
|
|
const selection = window.getSelection()
|
|
|
|
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) {
|
|
const len = element.textContent?.length || 0
|
|
|
|
return { start: len, end: len }
|
|
}
|
|
|
|
const range = selection.getRangeAt(0)
|
|
const startRange = document.createRange()
|
|
|
|
startRange.selectNodeContents(element)
|
|
startRange.setEnd(range.startContainer, range.startOffset)
|
|
const start = startRange.toString().length
|
|
const endRange = document.createRange()
|
|
|
|
endRange.selectNodeContents(element)
|
|
endRange.setEnd(range.endContainer, range.endOffset)
|
|
|
|
return {
|
|
start,
|
|
end: endRange.toString().length
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 붙여넣기용 블록 목록에 새 id를 부여한다.
|
|
* @param {Array<Object>} blocks - 파싱된 블록
|
|
* @returns {Array<Object>} id가 갱신된 블록
|
|
*/
|
|
const cloneBlocksWithNewIds = (blocks) => blocks.map((block) => ({
|
|
...block,
|
|
id: `editor-block-${(blockIdSeed += 1)}`
|
|
}))
|
|
|
|
/**
|
|
* 텍스트를 앞뒤 잘라낸 뒤 붙인 블록으로 현재 블록을 대체한다.
|
|
* @param {number} index - 대상 블록 인덱스
|
|
* @param {string} clipboardText - 클립보드 순수 텍스트
|
|
* @param {string} before - 커서 앞에 남길 텍스트
|
|
* @param {string} after - 커서 뒤에 남길 텍스트
|
|
* @returns {void}
|
|
*/
|
|
const applyStructuredPaste = (index, clipboardText, before, after) => {
|
|
let inserted = parseMarkdownToBlocks(clipboardText)
|
|
|
|
inserted = cloneBlocksWithNewIds(inserted)
|
|
|
|
if (!inserted.length) {
|
|
return
|
|
}
|
|
|
|
const mergeableTypes = ['paragraph', 'heading', 'quote', 'list', 'code', 'callout']
|
|
const first = inserted[0]
|
|
|
|
if (before) {
|
|
if (mergeableTypes.includes(first.type)) {
|
|
first.text = `${before}${first.text || ''}`
|
|
} else {
|
|
inserted = [createEditorBlock('paragraph', before), ...inserted]
|
|
}
|
|
}
|
|
|
|
const last = inserted[inserted.length - 1]
|
|
|
|
if (after) {
|
|
if (mergeableTypes.includes(last.type)) {
|
|
last.text = `${last.text || ''}${after}`
|
|
} else {
|
|
inserted = [...inserted, createEditorBlock('paragraph', after)]
|
|
}
|
|
}
|
|
|
|
clearBlockRangeSelection()
|
|
editorBlocks.value.splice(index, 1, ...inserted)
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
nextTick(() => focusBlock(index + inserted.length - 1, 'end'))
|
|
}
|
|
|
|
/**
|
|
* 여러 줄·마크다운 붙여넣기를 블록 단위로 반영한다.
|
|
* @param {ClipboardEvent} event - 붙여넣기 이벤트
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const handleTextBlockPaste = (event, index) => {
|
|
const block = editorBlocks.value[index]
|
|
|
|
if (!block || !isTextBlock(block) || isComposingText.value) {
|
|
return
|
|
}
|
|
|
|
const data = event.clipboardData
|
|
|
|
if (!data || data.files?.length) {
|
|
return
|
|
}
|
|
|
|
const clip = data.getData('text/plain') || ''
|
|
|
|
if (!shouldParseClipboardAsBlocks(clip, data.files)) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
const element = blockRefs.value[index]
|
|
|
|
if (!element) {
|
|
return
|
|
}
|
|
|
|
const { start, end } = getPlainTextOffsetsWithinElement(element)
|
|
const domText = getTextBlockDomText(index)
|
|
const before = domText.slice(0, start)
|
|
const after = domText.slice(end)
|
|
|
|
applyStructuredPaste(index, clip, before, after)
|
|
}
|
|
|
|
/**
|
|
* 블록마다 분리된 contenteditable에서는 문서 전체 선택이 불가하므로, Cmd/Ctrl+A 시 마크다운을 클립보드에 담는다. 블록 범위 선택 중이면 해당 구간만 복사한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @param {number} index - 포커스 블록 인덱스
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handleTextBlockSelectAll = async (event, index) => {
|
|
if (!(event.metaKey || event.ctrlKey) || event.shiftKey || String(event.key).toLowerCase() !== 'a') {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
const rangeSel = blockRangeSelection.value
|
|
const md = rangeSel
|
|
? serializeBlockIndexSlice(rangeSel.anchor, rangeSel.focus)
|
|
: serializeBlocks()
|
|
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(md)
|
|
|
|
if (rangeSel) {
|
|
flashEditorMessage('선택한 블록 구간(마크다운)을 클립보드에 복사했습니다.')
|
|
} else {
|
|
flashEditorMessage('전체 본문(마크다운)을 클립보드에 복사했습니다. 다른 편집기에 Cmd+V로 붙여넣을 수 있습니다.')
|
|
}
|
|
} else {
|
|
flashEditorMessage('이 브라우저에서는 클립보드 API를 사용할 수 없습니다.')
|
|
}
|
|
} catch {
|
|
flashEditorMessage('클립보드 복사에 실패했습니다. 주소가 https인지·사이트 권한을 확인해 주세요.')
|
|
}
|
|
|
|
nextTick(() => focusBlock(index, 'end'))
|
|
}
|
|
|
|
/**
|
|
* 텍스트 블록 공통 키다운(전체 복사 단축키 등)
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handleTextBlockKeydownRoot = async (event, index) => {
|
|
await handleTextBlockSelectAll(event, index)
|
|
}
|
|
|
|
/**
|
|
* 블록 타입에 맞는 태그명 반환
|
|
* @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 whitespace-pre-wrap text-[17px] leading-8': block.type === 'paragraph',
|
|
'admin-block-editor__heading 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 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 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 {string} width - 이미지 너비 옵션
|
|
* @returns {string} 클래스 문자열
|
|
*/
|
|
const getImageWidthClass = (width) => {
|
|
if (width === 'full') {
|
|
return 'admin-block-editor__image--full lg:-mx-20'
|
|
}
|
|
|
|
if (width === 'wide') {
|
|
return 'admin-block-editor__image--wide lg:-mx-10'
|
|
}
|
|
|
|
return 'admin-block-editor__image--regular'
|
|
}
|
|
|
|
/**
|
|
* 블록 텍스트 입력 처리
|
|
* @param {InputEvent} event - 입력 이벤트
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const updateBlockText = (event, index) => {
|
|
enableKeyboardPriorityMode()
|
|
|
|
const block = editorBlocks.value[index]
|
|
const text = getTextBlockDomText(index)
|
|
|
|
block.text = text
|
|
activeBlockId.value = block.id
|
|
|
|
if (isComposingText.value || event.isComposing) {
|
|
return
|
|
}
|
|
|
|
applyMarkdownShortcut(block, index)
|
|
updateSlashQuery(block)
|
|
updateSlashMenuDirection(index)
|
|
emitContent()
|
|
}
|
|
|
|
/**
|
|
* 한글 등 조합형 텍스트 입력 시작 처리
|
|
* @returns {void}
|
|
*/
|
|
const startTextComposition = () => {
|
|
isComposingText.value = true
|
|
}
|
|
|
|
/**
|
|
* 한글 등 조합형 텍스트 입력 종료 처리
|
|
* @param {CompositionEvent} event - 조합 종료 이벤트
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const finishTextComposition = (event, index) => {
|
|
isComposingText.value = false
|
|
|
|
const syncAfterComposition = () => {
|
|
const block = syncTextBlockFromDom(index)
|
|
|
|
if (!block) {
|
|
pendingSoftLineBreakIndex.value = -1
|
|
pendingSlashCommandIndex.value = -1
|
|
return
|
|
}
|
|
|
|
applyMarkdownShortcut(block, index)
|
|
|
|
const trimmedText = (block.text || '').trim()
|
|
const canApplyPendingSlashCommand = pendingSlashCommandIndex.value === index
|
|
&& block.text.startsWith('/')
|
|
&& visibleCommands.value.length > 0
|
|
&& trimmedText !== '/'
|
|
&& trimmedText !== '//'
|
|
|
|
if (canApplyPendingSlashCommand) {
|
|
pendingSlashCommandIndex.value = -1
|
|
const command = highlightedCommand.value || visibleCommands.value[0]
|
|
|
|
if (command) {
|
|
applyCommand(command)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (pendingSoftLineBreakIndex.value === index && isTextBlock(block) && block.type !== 'code') {
|
|
pendingSoftLineBreakIndex.value = -1
|
|
pendingSlashCommandIndex.value = -1
|
|
insertSoftLineBreak(index)
|
|
return
|
|
}
|
|
|
|
pendingSoftLineBreakIndex.value = -1
|
|
pendingSlashCommandIndex.value = -1
|
|
emitContent()
|
|
}
|
|
|
|
nextTick(() => {
|
|
syncAfterComposition()
|
|
window.setTimeout(syncAfterComposition, 0)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 마크다운 입력 단축 문법 적용
|
|
* @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', exact: true },
|
|
{ marker: '``` ', type: 'code' }
|
|
].sort((a, b) => b.marker.length - a.marker.length)
|
|
|
|
const shortcut = shortcutMap.find((item) => item.exact
|
|
? block.text === item.marker
|
|
: 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.textContent = block.text
|
|
focusBlock(index)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 검색어 갱신
|
|
* @param {Object} block - 에디터 블록
|
|
* @returns {void}
|
|
*/
|
|
const updateSlashQuery = (block) => {
|
|
const nextSlashQuery = block.text.startsWith('/')
|
|
? block.text.slice(1).trim().toLowerCase()
|
|
: ''
|
|
const hasQueryChanged = slashQuery.value !== nextSlashQuery
|
|
slashQuery.value = nextSlashQuery
|
|
|
|
if (hasQueryChanged) {
|
|
highlightedCommandIndex.value = 0
|
|
return
|
|
}
|
|
|
|
if (!visibleCommands.value.length) {
|
|
highlightedCommandIndex.value = 0
|
|
return
|
|
}
|
|
|
|
if (highlightedCommandIndex.value >= visibleCommands.value.length) {
|
|
highlightedCommandIndex.value = visibleCommands.value.length - 1
|
|
}
|
|
}
|
|
|
|
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)))
|
|
})
|
|
|
|
const highlightedCommand = computed(() => visibleCommands.value[highlightedCommandIndex.value])
|
|
const activeCalloutBlock = computed(() => {
|
|
const activeBlock = editorBlocks.value.find((block) => block.id === activeBlockId.value)
|
|
|
|
if (activeBlock?.type === 'callout') {
|
|
return activeBlock
|
|
}
|
|
|
|
const selectedBlock = editorBlocks.value.find((block) => block.id === selectedBlockId.value)
|
|
|
|
if (selectedBlock?.type === 'callout') {
|
|
return selectedBlock
|
|
}
|
|
|
|
return null
|
|
})
|
|
|
|
/**
|
|
* 슬래시 메뉴 명령 적용
|
|
* @param {Object} command - 블록 명령
|
|
* @returns {void}
|
|
*/
|
|
const applyCommand = (command) => {
|
|
clearBlockRangeSelection()
|
|
const index = activeBlockIndex.value
|
|
|
|
if (index < 0) {
|
|
return
|
|
}
|
|
|
|
const block = editorBlocks.value[index]
|
|
block.type = command.type
|
|
block.level = command.level || null
|
|
block.text = ''
|
|
block.url = ''
|
|
block.alt = ''
|
|
block.title = ''
|
|
block.width = 'regular'
|
|
block.images = []
|
|
const element = blockRefs.value[index]
|
|
|
|
if (element) {
|
|
element.textContent = ''
|
|
}
|
|
|
|
slashQuery.value = ''
|
|
|
|
if (command.type === 'divider') {
|
|
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
focusBlock(index + 1)
|
|
return
|
|
}
|
|
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
|
|
if (isTextBlock(block)) {
|
|
focusBlock(index)
|
|
return
|
|
}
|
|
|
|
if (['toggle', 'embed'].includes(block.type)) {
|
|
focusStructuredBlock(index)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 블록 업로드 진행 상태 설정
|
|
* @param {string} blockId - 블록 ID
|
|
* @param {boolean} isUploading - 업로드 진행 여부
|
|
* @returns {void}
|
|
*/
|
|
const setUploading = (blockId, isUploading) => {
|
|
uploadingBlockIds.value = isUploading
|
|
? [...new Set([...uploadingBlockIds.value, blockId])]
|
|
: uploadingBlockIds.value.filter((id) => id !== blockId)
|
|
}
|
|
|
|
/**
|
|
* 블록 업로드 진행 상태 반환
|
|
* @param {string} blockId - 블록 ID
|
|
* @returns {boolean} 업로드 진행 여부
|
|
*/
|
|
const isUploading = (blockId) => uploadingBlockIds.value.includes(blockId)
|
|
|
|
/**
|
|
* 이미지 파일 업로드
|
|
* @param {FileList|Array<File>} files - 업로드 파일 목록
|
|
* @returns {Promise<Array<Object>>} 업로드된 파일 목록
|
|
*/
|
|
const uploadImages = async (files) => {
|
|
const formData = new FormData()
|
|
|
|
Array.from(files).forEach((file) => {
|
|
formData.append('files', file)
|
|
})
|
|
|
|
const result = await $fetch('/admin/api/uploads', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
|
|
return result.files || []
|
|
}
|
|
|
|
/**
|
|
* 미디어 라이브러리 목록 조회
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const fetchMediaItems = async () => {
|
|
isLoadingMedia.value = true
|
|
|
|
try {
|
|
mediaItems.value = await $fetch('/admin/api/media')
|
|
} finally {
|
|
isLoadingMedia.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 창 열기
|
|
* @param {Object} block - 대상 블록
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const openMediaPicker = async (block) => {
|
|
mediaPickerTarget.value = {
|
|
blockId: block.id,
|
|
type: block.type
|
|
}
|
|
selectedGalleryMediaUrls.value = block.type === 'gallery'
|
|
? block.images.map((image) => image.url).filter(Boolean)
|
|
: []
|
|
isMediaPickerOpen.value = true
|
|
await fetchMediaItems()
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 창 닫기
|
|
* @returns {void}
|
|
*/
|
|
const closeMediaPicker = () => {
|
|
isMediaPickerOpen.value = false
|
|
mediaPickerTarget.value = null
|
|
selectedGalleryMediaUrls.value = []
|
|
}
|
|
|
|
/**
|
|
* 갤러리 미디어 선택 상태 전환
|
|
* @param {Object} mediaItem - 미디어 항목
|
|
* @returns {void}
|
|
*/
|
|
const toggleGalleryMediaSelection = (mediaItem) => {
|
|
if (selectedGalleryMediaUrls.value.includes(mediaItem.url)) {
|
|
selectedGalleryMediaUrls.value = selectedGalleryMediaUrls.value.filter((url) => url !== mediaItem.url)
|
|
return
|
|
}
|
|
|
|
selectedGalleryMediaUrls.value = [...selectedGalleryMediaUrls.value, mediaItem.url]
|
|
}
|
|
|
|
/**
|
|
* 갤러리 미디어 선택을 블록에 적용
|
|
* @returns {void}
|
|
*/
|
|
const applyGalleryMediaSelection = () => {
|
|
const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId)
|
|
|
|
if (!block || mediaPickerTarget.value?.type !== 'gallery') {
|
|
return
|
|
}
|
|
|
|
const existingImages = new Map(block.images.map((image) => [image.url, image]))
|
|
block.images = selectedGalleryMediaUrls.value.map((url) => existingImages.get(url) || {
|
|
url,
|
|
alt: '',
|
|
width: 'regular'
|
|
})
|
|
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
closeMediaPicker()
|
|
}
|
|
|
|
/**
|
|
* 선택한 미디어를 블록에 적용
|
|
* @param {Object} mediaItem - 미디어 항목
|
|
* @returns {void}
|
|
*/
|
|
const selectMediaItem = (mediaItem) => {
|
|
if (mediaPickerTarget.value?.type === 'gallery') {
|
|
toggleGalleryMediaSelection(mediaItem)
|
|
return
|
|
}
|
|
|
|
const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId)
|
|
|
|
if (!block) {
|
|
return
|
|
}
|
|
|
|
block.url = mediaItem.url
|
|
block.alt = ''
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
closeMediaPicker()
|
|
}
|
|
|
|
/**
|
|
* 단일 이미지 파일 선택 처리
|
|
* @param {Event} event - 파일 입력 이벤트
|
|
* @param {Object} block - 이미지 블록
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handleImageUpload = async (event, block) => {
|
|
const files = event.target.files
|
|
|
|
if (!files?.length) {
|
|
return
|
|
}
|
|
|
|
setUploading(block.id, true)
|
|
|
|
try {
|
|
const [file] = await uploadImages(files)
|
|
|
|
if (file) {
|
|
block.url = file.url
|
|
block.alt = ''
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
}
|
|
} finally {
|
|
event.target.value = ''
|
|
setUploading(block.id, false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 갤러리 이미지 파일 선택 처리
|
|
* @param {Event} event - 파일 입력 이벤트
|
|
* @param {Object} block - 갤러리 블록
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handleGalleryUpload = async (event, block) => {
|
|
const files = event.target.files
|
|
|
|
if (!files?.length) {
|
|
return
|
|
}
|
|
|
|
setUploading(block.id, true)
|
|
|
|
try {
|
|
const uploadedFiles = await uploadImages(files)
|
|
block.images = [
|
|
...block.images,
|
|
...uploadedFiles.map((file) => ({
|
|
url: file.url,
|
|
alt: '',
|
|
width: 'regular'
|
|
}))
|
|
]
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
} finally {
|
|
event.target.value = ''
|
|
setUploading(block.id, false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 이미지 너비 옵션 변경
|
|
* @param {Object} block - 이미지 블록
|
|
* @param {string} width - 너비 옵션
|
|
* @returns {void}
|
|
*/
|
|
const updateImageWidth = (block, width) => {
|
|
block.width = width
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
}
|
|
|
|
/**
|
|
* 갤러리 이미지 삭제
|
|
* @param {Object} block - 갤러리 블록
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const removeGalleryImage = (block, imageIndex) => {
|
|
block.images.splice(imageIndex, 1)
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
}
|
|
|
|
/**
|
|
* 갤러리 이미지 드래그 시작
|
|
* @param {DragEvent} event - 드래그 이벤트
|
|
* @param {Object} block - 갤러리 블록
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const startGalleryImageDrag = (event, block, imageIndex) => {
|
|
draggingGalleryImage.value = {
|
|
blockId: block.id,
|
|
imageIndex
|
|
}
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
event.dataTransfer.setData('text/plain', `${block.id}:${imageIndex}`)
|
|
}
|
|
|
|
/**
|
|
* 갤러리 이미지 드래그 종료
|
|
* @returns {void}
|
|
*/
|
|
const finishGalleryImageDrag = () => {
|
|
draggingGalleryImage.value = null
|
|
galleryDragTarget.value = null
|
|
}
|
|
|
|
/**
|
|
* 갤러리 이미지 삽입 위치 표시
|
|
* @param {DragEvent} event - 드래그 이벤트
|
|
* @param {Object} block - 갤러리 블록
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const updateGalleryImageDropTarget = (event, block, imageIndex) => {
|
|
const source = draggingGalleryImage.value
|
|
|
|
if (!source || source.blockId !== block.id) {
|
|
return
|
|
}
|
|
|
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
galleryDragTarget.value = {
|
|
blockId: block.id,
|
|
imageIndex,
|
|
position: event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 갤러리 이미지 순서 변경
|
|
* @param {DragEvent} event - 드롭 이벤트
|
|
* @param {Object} block - 갤러리 블록
|
|
* @param {number} targetIndex - 대상 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const dropGalleryImage = (event, block, targetIndex) => {
|
|
const source = draggingGalleryImage.value
|
|
const target = galleryDragTarget.value
|
|
|
|
if (!source || source.blockId !== block.id || source.imageIndex === targetIndex) {
|
|
return
|
|
}
|
|
|
|
let nextTargetIndex = target?.blockId === block.id && target.position === 'after'
|
|
? targetIndex + 1
|
|
: targetIndex
|
|
|
|
if (source.imageIndex < nextTargetIndex) {
|
|
nextTargetIndex -= 1
|
|
}
|
|
|
|
if (source.imageIndex === nextTargetIndex) {
|
|
finishGalleryImageDrag()
|
|
return
|
|
}
|
|
|
|
const [image] = block.images.splice(source.imageIndex, 1)
|
|
block.images.splice(nextTargetIndex, 0, image)
|
|
finishGalleryImageDrag()
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 선택을 아래로 이동
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const highlightNextCommand = (event) => {
|
|
const block = editorBlocks.value[activeBlockIndex.value]
|
|
|
|
if (!block?.text?.startsWith('/')) {
|
|
return
|
|
}
|
|
|
|
syncTextBlockFromDom(activeBlockIndex.value)
|
|
|
|
if (!visibleCommands.value.length) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
highlightedCommandIndex.value = (highlightedCommandIndex.value + 1) % visibleCommands.value.length
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 선택을 위로 이동
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const highlightPreviousCommand = (event) => {
|
|
const block = editorBlocks.value[activeBlockIndex.value]
|
|
|
|
if (!block?.text?.startsWith('/')) {
|
|
return
|
|
}
|
|
|
|
syncTextBlockFromDom(activeBlockIndex.value)
|
|
|
|
if (!visibleCommands.value.length) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
highlightedCommandIndex.value = highlightedCommandIndex.value === 0
|
|
? visibleCommands.value.length - 1
|
|
: highlightedCommandIndex.value - 1
|
|
}
|
|
|
|
/**
|
|
* 현재 하이라이트된 슬래시 메뉴 항목을 스크롤 영역에 맞춘다.
|
|
* @returns {void}
|
|
*/
|
|
const scrollHighlightedCommandIntoView = () => {
|
|
nextTick(() => {
|
|
if (!activeBlockId.value || !visibleCommands.value.length) {
|
|
return
|
|
}
|
|
|
|
const row = document.querySelector(`[data-editor-block-id="${activeBlockId.value}"]`)
|
|
const menu = row?.querySelector('.admin-block-editor__slash-menu')
|
|
const highlightedItem = row?.querySelector('.admin-block-editor__slash-item--active')
|
|
|
|
if (!menu || !highlightedItem) {
|
|
return
|
|
}
|
|
|
|
highlightedItem.scrollIntoView({
|
|
block: 'nearest'
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 블록 단위 범위 선택을 해제한다.
|
|
* @returns {void}
|
|
*/
|
|
const clearBlockRangeSelection = () => {
|
|
blockRangeSelection.value = null
|
|
isBlockRangeDragging.value = false
|
|
}
|
|
|
|
/**
|
|
* 블록 인덱스가 범위 선택에 포함되는지 여부
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {boolean} 포함 여부
|
|
*/
|
|
const isBlockRangeRowSelected = (index) => {
|
|
const sel = blockRangeSelection.value
|
|
|
|
if (!sel) {
|
|
return false
|
|
}
|
|
|
|
const lo = Math.min(sel.anchor, sel.focus)
|
|
const hi = Math.max(sel.anchor, sel.focus)
|
|
|
|
return index >= lo && index <= hi
|
|
}
|
|
|
|
/**
|
|
* 포인터 좌표에 해당하는 행 블록 인덱스를 찾는다.
|
|
* elementFromPoint가 행 밖 여백(블록 간 margin 등)을 가리키면 세로 거리로 가장 가까운 행을 고른다.
|
|
* @param {number} clientX - 뷰포트 X
|
|
* @param {number} clientY - 뷰포트 Y
|
|
* @returns {number} 인덱스, 없으면 -1
|
|
*/
|
|
const resolveBlockIndexFromPointer = (clientX, clientY) => {
|
|
const root = blockEditorRootRef.value
|
|
|
|
if (!root || typeof document === 'undefined') {
|
|
return -1
|
|
}
|
|
|
|
const topEl = document.elementFromPoint(clientX, clientY)
|
|
const directRow = topEl?.closest?.('[data-editor-block-id]')
|
|
|
|
if (directRow && root.contains(directRow)) {
|
|
const id = directRow.getAttribute('data-editor-block-id')
|
|
const idx = editorBlocks.value.findIndex((b) => b.id === id)
|
|
|
|
if (idx >= 0) {
|
|
return idx
|
|
}
|
|
}
|
|
|
|
const idToIndex = new Map(editorBlocks.value.map((b, i) => [b.id, i]))
|
|
let bestIdx = -1
|
|
let bestDelta = Infinity
|
|
|
|
root.querySelectorAll('[data-editor-block-id]').forEach((row) => {
|
|
const id = row.getAttribute('data-editor-block-id')
|
|
const i = id == null ? -1 : idToIndex.get(id)
|
|
|
|
if (i === undefined || i < 0) {
|
|
return
|
|
}
|
|
|
|
const r = row.getBoundingClientRect()
|
|
let delta = 0
|
|
|
|
if (clientY < r.top) {
|
|
delta = r.top - clientY
|
|
} else if (clientY > r.bottom) {
|
|
delta = clientY - r.bottom
|
|
}
|
|
|
|
if (delta < bestDelta) {
|
|
bestDelta = delta
|
|
bestIdx = i
|
|
}
|
|
})
|
|
|
|
const snapPx = 72
|
|
|
|
if (bestIdx >= 0 && bestDelta <= snapPx) {
|
|
return bestIdx
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
/**
|
|
* 범위 선택 레인에서 포인터로 블록 범위 드래그를 시작한다.
|
|
* @param {PointerEvent} event - 포인터 이벤트
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const onBlockRangeLanePointerDown = (event, index) => {
|
|
if (event.button !== 0) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
if (event.shiftKey) {
|
|
if (blockRangeSelection.value) {
|
|
blockRangeSelection.value = {
|
|
anchor: blockRangeSelection.value.anchor,
|
|
focus: index
|
|
}
|
|
} else {
|
|
blockRangeSelection.value = { anchor: index, focus: index }
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
const anchorIndex = index
|
|
|
|
isBlockRangeDragging.value = true
|
|
blockRangeSelection.value = { anchor: anchorIndex, focus: anchorIndex }
|
|
|
|
/**
|
|
* 드래그 중 포인터 위치의 블록으로 범위 끝을 갱신한다.
|
|
* @param {PointerEvent} ev - 포인터 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onMove = (ev) => {
|
|
if (!isBlockRangeDragging.value) {
|
|
return
|
|
}
|
|
|
|
const idx = resolveBlockIndexFromPointer(ev.clientX, ev.clientY)
|
|
|
|
if (idx < 0) {
|
|
return
|
|
}
|
|
|
|
blockRangeSelection.value = {
|
|
anchor: anchorIndex,
|
|
focus: idx
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 드래그 종료 시 문서 리스너를 제거한다.
|
|
* @returns {void}
|
|
*/
|
|
const onUp = () => {
|
|
isBlockRangeDragging.value = false
|
|
document.removeEventListener('pointermove', onMove)
|
|
document.removeEventListener('pointerup', onUp)
|
|
document.removeEventListener('pointercancel', onUp)
|
|
}
|
|
|
|
document.addEventListener('pointermove', onMove, { passive: true })
|
|
document.addEventListener('pointerup', onUp, { passive: true })
|
|
document.addEventListener('pointercancel', onUp, { passive: true })
|
|
}
|
|
|
|
/**
|
|
* 블록 범위 선택이 있어도 브라우저 기본 복사를 우선해야 하는지 판별한다.
|
|
* contenteditable 안의 비접힘 선택 또는 textarea/input의 선택 구간이 있으면 true.
|
|
* @returns {boolean} 기본 복사를 그대로 두면 true
|
|
*/
|
|
const shouldDeferBlockRangeCopyToNative = () => {
|
|
if (typeof document === 'undefined') {
|
|
return false
|
|
}
|
|
|
|
const activeEl = document.activeElement
|
|
|
|
if (activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLInputElement) {
|
|
const { selectionStart, selectionEnd } = activeEl
|
|
|
|
if (selectionStart != null && selectionEnd != null && selectionStart !== selectionEnd) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (typeof window === 'undefined') {
|
|
return false
|
|
}
|
|
|
|
const sel = window.getSelection()
|
|
|
|
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
|
return false
|
|
}
|
|
|
|
const range = sel.getRangeAt(0)
|
|
let ancestor = range.commonAncestorContainer
|
|
|
|
if (ancestor.nodeType === Node.TEXT_NODE) {
|
|
ancestor = ancestor.parentElement
|
|
}
|
|
|
|
if (!ancestor || typeof ancestor.closest !== 'function') {
|
|
return false
|
|
}
|
|
|
|
const host = ancestor.closest('[contenteditable="true"]')
|
|
|
|
if (!host) {
|
|
return false
|
|
}
|
|
|
|
return blockRefs.value.some((el) => el === host)
|
|
}
|
|
|
|
/**
|
|
* 에디터 루트에서 범위 선택이 있을 때 복사 시 마크다운만 클립보드에 넣는다.
|
|
* @param {ClipboardEvent} event - 복사 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const handleEditorRootCopy = (event) => {
|
|
if (!blockRangeSelection.value) {
|
|
return
|
|
}
|
|
|
|
if (shouldDeferBlockRangeCopyToNative()) {
|
|
return
|
|
}
|
|
|
|
const { anchor, focus } = blockRangeSelection.value
|
|
const md = serializeBlockIndexSlice(anchor, focus)
|
|
|
|
if (!md) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.clipboardData?.setData('text/plain', md)
|
|
}
|
|
|
|
/**
|
|
* 에디터 루트 키다운(Escape로 범위 해제 등)
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const handleEditorRootKeydown = (event) => {
|
|
if (event.key === 'Escape' && blockRangeSelection.value) {
|
|
event.preventDefault()
|
|
clearBlockRangeSelection()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 일반 본문 블록 방향키 이동 처리
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @param {number} index - 현재 블록 인덱스
|
|
* @param {'up'|'down'} direction - 이동 방향
|
|
* @returns {void}
|
|
*/
|
|
const navigateAcrossBlocks = (event, index, direction) => {
|
|
const currentBlock = editorBlocks.value[index]
|
|
|
|
if (!currentBlock || currentBlock.text.startsWith('/')) {
|
|
return
|
|
}
|
|
|
|
const currentElement = blockRefs.value[index]
|
|
|
|
if (!currentElement) {
|
|
return
|
|
}
|
|
|
|
if (event.shiftKey && blockRangeSelection.value) {
|
|
event.preventDefault()
|
|
const { anchor, focus } = blockRangeSelection.value
|
|
const nextFocus = direction === 'down'
|
|
? Math.min(editorBlocks.value.length - 1, focus + 1)
|
|
: Math.max(0, focus - 1)
|
|
|
|
blockRangeSelection.value = { anchor, focus: nextFocus }
|
|
return
|
|
}
|
|
|
|
const isBoundary = direction === 'up'
|
|
? isCaretOnBoundary(currentElement, 'start')
|
|
: isCaretOnBoundary(currentElement, 'end')
|
|
|
|
if (!isBoundary) {
|
|
return
|
|
}
|
|
|
|
const nextIndex = direction === 'up' ? index - 1 : index + 1
|
|
|
|
if (nextIndex < 0 || nextIndex >= editorBlocks.value.length) {
|
|
return
|
|
}
|
|
|
|
if (event.shiftKey) {
|
|
event.preventDefault()
|
|
blockRangeSelection.value = { anchor: index, focus: nextIndex }
|
|
return
|
|
}
|
|
|
|
clearBlockRangeSelection()
|
|
event.preventDefault()
|
|
const targetBlock = editorBlocks.value[nextIndex]
|
|
|
|
if (isTextBlock(targetBlock)) {
|
|
focusBlock(nextIndex, direction === 'up' ? 'end' : 'start')
|
|
return
|
|
}
|
|
|
|
focusStructuredBlock(nextIndex)
|
|
}
|
|
|
|
/**
|
|
* 엔터 키로 다음 블록 생성
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const handleEnter = (event, index) => {
|
|
enableKeyboardPriorityMode()
|
|
clearBlockRangeSelection()
|
|
|
|
const currentBlock = syncTextBlockFromDom(index)
|
|
|
|
if (isComposingText.value || event.isComposing || event.keyCode === 229) {
|
|
event.preventDefault()
|
|
|
|
if (event.shiftKey && isTextBlock(currentBlock) && currentBlock.type !== 'code') {
|
|
pendingSoftLineBreakIndex.value = index
|
|
}
|
|
|
|
if (!event.shiftKey && (currentBlock.text || '').startsWith('/')) {
|
|
pendingSlashCommandIndex.value = index
|
|
} else {
|
|
pendingSlashCommandIndex.value = -1
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (currentBlock.type === 'code') {
|
|
return
|
|
}
|
|
|
|
if (event.shiftKey && isTextBlock(currentBlock) && currentBlock.type !== 'code') {
|
|
event.preventDefault()
|
|
insertSoftLineBreak(index)
|
|
return
|
|
}
|
|
|
|
const trimmedText = (currentBlock.text || '').trim()
|
|
const canApplySlashCommand = currentBlock.text.startsWith('/')
|
|
&& visibleCommands.value.length > 0
|
|
&& trimmedText !== '/'
|
|
&& trimmedText !== '//'
|
|
|
|
if (canApplySlashCommand) {
|
|
event.preventDefault()
|
|
const command = highlightedCommand.value || visibleCommands.value[0]
|
|
|
|
if (command) {
|
|
applyCommand(command)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
|
|
if (['divider', 'image', 'gallery', 'toggle', 'embed'].includes(currentBlock.type)) {
|
|
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
focusBlock(index + 1)
|
|
return
|
|
}
|
|
|
|
if (!currentBlock.text.trim() && !['paragraph', 'callout'].includes(currentBlock.type)) {
|
|
currentBlock.type = 'paragraph'
|
|
currentBlock.level = null
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
focusBlock(index)
|
|
return
|
|
}
|
|
|
|
const nextType = currentBlock.type === 'list' ? 'list' : 'paragraph'
|
|
editorBlocks.value.splice(index + 1, 0, createEditorBlock(nextType))
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
focusBlock(index + 1)
|
|
}
|
|
|
|
/**
|
|
* 백스페이스 키로 빈 블록 삭제
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const handleBackspace = (event, index) => {
|
|
const block = editorBlocks.value[index]
|
|
|
|
if ((isTextBlock(block) && block.text) || editorBlocks.value.length <= 1) {
|
|
return
|
|
}
|
|
|
|
if (!isTextBlock(block) && (block.url || block.images.length || block.title || block.text)) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
clearBlockRangeSelection()
|
|
editorBlocks.value.splice(index, 1)
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
focusBlock(Math.max(index - 1, 0))
|
|
}
|
|
|
|
/**
|
|
* 블록 인덱스 반환
|
|
* @param {string} blockId - 블록 ID
|
|
* @returns {number} 블록 인덱스
|
|
*/
|
|
const getBlockIndex = (blockId) => editorBlocks.value.findIndex((block) => block.id === blockId)
|
|
|
|
/**
|
|
* 포커스 이탈 시 자동 정리할 구조형 블록인지 확인
|
|
* @param {Object|undefined} block - 에디터 블록
|
|
* @returns {boolean} 자동 정리 대상 여부
|
|
*/
|
|
const isDiscardableStructuredBlock = (block) => {
|
|
if (!block) {
|
|
return false
|
|
}
|
|
|
|
if (block.type === 'image') {
|
|
return !block.url
|
|
}
|
|
|
|
if (block.type === 'gallery') {
|
|
return !block.images.length
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* 다른 블록으로 이동할 때 미사용 구조형 블록을 정리
|
|
* @param {string} nextBlockId - 다음 활성 블록 ID
|
|
* @returns {void}
|
|
*/
|
|
const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => {
|
|
const previousActiveBlockId = activeBlockId.value
|
|
|
|
if (!previousActiveBlockId || previousActiveBlockId === nextBlockId) {
|
|
return
|
|
}
|
|
|
|
const previousBlockIndex = getBlockIndex(previousActiveBlockId)
|
|
const previousBlock = editorBlocks.value[previousBlockIndex]
|
|
|
|
if (!isDiscardableStructuredBlock(previousBlock) || editorBlocks.value.length <= 1) {
|
|
return
|
|
}
|
|
|
|
clearBlockRangeSelection()
|
|
editorBlocks.value.splice(previousBlockIndex, 1)
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
}
|
|
|
|
/**
|
|
* 블록 선택 상태 적용
|
|
* @param {Object} block - 선택할 블록
|
|
* @returns {void}
|
|
*/
|
|
const selectBlock = (block) => {
|
|
clearBlockRangeSelection()
|
|
selectedBlockId.value = block.id
|
|
activeBlockId.value = block.id
|
|
slashQuery.value = ''
|
|
}
|
|
|
|
/**
|
|
* 지정 블록 삭제
|
|
* @param {number} index - 삭제할 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const deleteBlock = (index) => {
|
|
if (index < 0) {
|
|
return
|
|
}
|
|
|
|
if (editorBlocks.value.length <= 1) {
|
|
editorBlocks.value.splice(0, 1, createEditorBlock())
|
|
selectedBlockId.value = ''
|
|
activeBlockId.value = editorBlocks.value[0].id
|
|
clearBlockRangeSelection()
|
|
emitContent()
|
|
focusBlock(0)
|
|
return
|
|
}
|
|
|
|
editorBlocks.value.splice(index, 1)
|
|
selectedBlockId.value = ''
|
|
clearBlockRangeSelection()
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
focusBlock(Math.min(index, editorBlocks.value.length - 1))
|
|
}
|
|
|
|
/**
|
|
* 선택한 블록 삭제 키 처리
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @param {number} index - 삭제할 블록 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const deleteSelectedBlock = (event, index) => {
|
|
event.preventDefault()
|
|
deleteBlock(index)
|
|
}
|
|
|
|
/**
|
|
* 블록 드래그 시작 처리
|
|
* @param {DragEvent} event - 드래그 이벤트
|
|
* @param {Object} block - 드래그할 블록
|
|
* @returns {void}
|
|
*/
|
|
const startBlockDrag = (event, block) => {
|
|
clearBlockRangeSelection()
|
|
draggingBlockId.value = block.id
|
|
selectedBlockId.value = block.id
|
|
dragTargetIndex.value = -1
|
|
dragTargetPosition.value = ''
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
event.dataTransfer.setData('text/plain', block.id)
|
|
}
|
|
|
|
/**
|
|
* 블록 드래그 중 드롭 위치 표시 갱신
|
|
* @param {DragEvent} event - 드래그 이벤트
|
|
* @param {number} targetIndex - 드래그 중인 대상 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const updateBlockDropTarget = (event, targetIndex) => {
|
|
if (!draggingBlockId.value) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.dataTransfer.dropEffect = 'move'
|
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
dragTargetIndex.value = targetIndex
|
|
dragTargetPosition.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
|
}
|
|
|
|
/**
|
|
* 블록 드래그 상태 초기화
|
|
* @returns {void}
|
|
*/
|
|
const clearBlockDragState = () => {
|
|
draggingBlockId.value = ''
|
|
dragTargetIndex.value = -1
|
|
dragTargetPosition.value = ''
|
|
}
|
|
|
|
/**
|
|
* 현재 드롭 위치 기준으로 블록 이동
|
|
* @param {string} draggedId - 이동할 블록 ID
|
|
* @param {number} targetIndex - 이동 대상 인덱스
|
|
* @param {string} targetPosition - 이동 위치
|
|
* @returns {boolean} 이동 여부
|
|
*/
|
|
const moveDraggedBlock = (draggedId, targetIndex, targetPosition) => {
|
|
const sourceIndex = getBlockIndex(draggedId)
|
|
const insertionIndex = targetPosition === 'after' ? targetIndex + 1 : targetIndex
|
|
|
|
if (sourceIndex < 0 || targetIndex < 0) {
|
|
return false
|
|
}
|
|
|
|
const nextTargetIndex = sourceIndex < insertionIndex ? insertionIndex - 1 : insertionIndex
|
|
|
|
if (sourceIndex === nextTargetIndex) {
|
|
return false
|
|
}
|
|
|
|
clearBlockRangeSelection()
|
|
const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1)
|
|
editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock)
|
|
selectedBlockId.value = draggedBlock.id
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* 블록 드롭 이동 처리
|
|
* @param {DragEvent} event - 드롭 이벤트
|
|
* @param {number} targetIndex - 이동 대상 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const dropBlock = (event, targetIndex) => {
|
|
event.preventDefault()
|
|
const draggedId = event.dataTransfer.getData('text/plain') || draggingBlockId.value
|
|
const targetPosition = dragTargetIndex.value === targetIndex ? dragTargetPosition.value : 'after'
|
|
|
|
moveDraggedBlock(draggedId, targetIndex, targetPosition)
|
|
clearBlockDragState()
|
|
}
|
|
|
|
/**
|
|
* 블록 드래그 종료 처리
|
|
* @returns {void}
|
|
*/
|
|
const finishBlockDrag = () => {
|
|
if (draggingBlockId.value && dragTargetIndex.value >= 0 && dragTargetPosition.value) {
|
|
moveDraggedBlock(draggingBlockId.value, dragTargetIndex.value, dragTargetPosition.value)
|
|
}
|
|
|
|
clearBlockDragState()
|
|
}
|
|
|
|
/**
|
|
* 현재 블록 활성화
|
|
* @param {Object} block - 에디터 블록
|
|
* @returns {void}
|
|
*/
|
|
const activateBlock = (block) => {
|
|
cleanupUnusedStructuredBlockOnActivate(block.id)
|
|
|
|
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
|
|
activeBlockId.value = block.id
|
|
selectedBlockId.value = ''
|
|
clearBlockRangeSelection()
|
|
updateSlashQuery(block)
|
|
updateSlashMenuDirection(index)
|
|
}
|
|
|
|
/**
|
|
* 블록 placeholder 표시 여부 반환
|
|
* @param {Object} block - 에디터 블록
|
|
* @param {number} index - 블록 인덱스
|
|
* @returns {boolean} placeholder 표시 여부
|
|
*/
|
|
const shouldShowPlaceholder = (block, index) => !block.text && (
|
|
(index === 0 && editorBlocks.value.length === 1) ||
|
|
index === editorBlocks.value.length - 1
|
|
)
|
|
|
|
/**
|
|
* 텍스트 필드 변경 내용을 저장용 콘텐츠에 반영
|
|
* @returns {void}
|
|
*/
|
|
const updateStructuredBlock = () => {
|
|
normalizeTrailingTextBlock()
|
|
emitContent()
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 이모지 표시 상태를 전환
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @returns {void}
|
|
*/
|
|
const toggleCalloutEmoji = (block) => {
|
|
block.calloutEmojiEnabled = !block.calloutEmojiEnabled
|
|
if (!block.calloutEmojiEnabled) {
|
|
calloutEmojiPickerBlockId.value = ''
|
|
}
|
|
updateStructuredBlock()
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 이모지를 변경
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @param {string} emoji - 이모지 문자열
|
|
* @returns {void}
|
|
*/
|
|
const updateCalloutEmoji = (block, emoji) => {
|
|
block.calloutEmoji = emoji || ''
|
|
block.calloutEmojiEnabled = true
|
|
updateStructuredBlock()
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 배경 프리셋을 변경
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @param {string} background - 배경 프리셋 값
|
|
* @returns {void}
|
|
*/
|
|
const updateCalloutBackground = (block, background) => {
|
|
block.calloutBackground = background
|
|
calloutColorPopoverBlockId.value = ''
|
|
updateStructuredBlock()
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 이모지 팝업 토글
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @returns {void}
|
|
*/
|
|
const toggleCalloutEmojiPicker = (block) => {
|
|
calloutEmojiPickerBlockId.value = calloutEmojiPickerBlockId.value === block.id
|
|
? ''
|
|
: block.id
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 배경색 팝오버 토글
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @returns {void}
|
|
*/
|
|
const toggleCalloutColorPopover = (block) => {
|
|
calloutColorPopoverBlockId.value = calloutColorPopoverBlockId.value === block.id
|
|
? ''
|
|
: block.id
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 이모지 입력값 적용
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @param {Event} event - 입력 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const updateCalloutEmojiFromInput = (block, event) => {
|
|
if (calloutEmojiComposingBlockId.value === block.id || event?.isComposing) {
|
|
return
|
|
}
|
|
|
|
const rawValue = String(event.target?.value || event.target?.textContent || '')
|
|
block.calloutEmoji = rawValue
|
|
block.calloutEmojiEnabled = true
|
|
updateStructuredBlock()
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 이모지 입력 조합 시작 처리
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @returns {void}
|
|
*/
|
|
const startCalloutEmojiComposition = (block) => {
|
|
calloutEmojiComposingBlockId.value = block.id
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 이모지 입력 조합 종료 처리
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @param {CompositionEvent} event - 조합 종료 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const finishCalloutEmojiComposition = (block, event) => {
|
|
calloutEmojiComposingBlockId.value = ''
|
|
updateCalloutEmojiFromInput(block, event)
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 이모지 입력을 1문자 슬롯으로 정규화
|
|
* @param {Object} block - 콜아웃 블록
|
|
* @param {FocusEvent} event - blur 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const normalizeCalloutEmojiInput = (block, event) => {
|
|
const rawValue = String(block.calloutEmoji || '').trim()
|
|
const graphemes = typeof Intl !== 'undefined' && Intl.Segmenter
|
|
? Array.from(new Intl.Segmenter('ko', { granularity: 'grapheme' }).segment(rawValue), (segment) => segment.segment)
|
|
: Array.from(rawValue)
|
|
const nextValue = graphemes.length ? graphemes[graphemes.length - 1] : ''
|
|
|
|
block.calloutEmoji = nextValue
|
|
updateStructuredBlock()
|
|
|
|
if (event?.target && 'value' in event.target) {
|
|
event.target.value = nextValue
|
|
}
|
|
}
|
|
|
|
watch(() => props.modelValue, (value) => {
|
|
if (isApplyingExternalValue.value) {
|
|
return
|
|
}
|
|
|
|
const currentValue = serializeBlocks()
|
|
|
|
if (value === currentValue && editorBlocks.value.length) {
|
|
return
|
|
}
|
|
|
|
editorBlocks.value = parseMarkdownToBlocks(value)
|
|
normalizeTrailingTextBlock()
|
|
}, { immediate: true })
|
|
|
|
watch(editorBlocks, () => {
|
|
normalizeTrailingTextBlock()
|
|
isApplyingExternalValue.value = true
|
|
nextTick(() => {
|
|
isApplyingExternalValue.value = false
|
|
})
|
|
}, { deep: true })
|
|
|
|
watch(
|
|
[highlightedCommandIndex, () => visibleCommands.value.length, activeBlockId],
|
|
() => {
|
|
scrollHighlightedCommandIntoView()
|
|
}
|
|
)
|
|
|
|
watch(activeCalloutBlock, (nextBlock) => {
|
|
if (!nextBlock) {
|
|
calloutEmojiPickerBlockId.value = ''
|
|
calloutColorPopoverBlockId.value = ''
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (import.meta.client && editorFlashTimer) {
|
|
window.clearTimeout(editorFlashTimer)
|
|
editorFlashTimer = null
|
|
}
|
|
})
|
|
|
|
defineExpose({
|
|
focusFirstBlock: () => focusBlock(0)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="blockEditorRootRef"
|
|
class="admin-block-editor bg-transparent py-4 text-ink"
|
|
:class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
|
|
@mousemove="handleEditorMouseMove"
|
|
@keydown="handleEditorRootKeydown"
|
|
@copy.capture="handleEditorRootCopy"
|
|
>
|
|
<p
|
|
v-if="editorFlashMessage"
|
|
class="admin-block-editor__flash-message mb-3 rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-900"
|
|
role="status"
|
|
>
|
|
{{ editorFlashMessage }}
|
|
</p>
|
|
<div class="admin-block-editor__surface post-prose">
|
|
<div
|
|
v-for="(block, index) in editorBlocks"
|
|
:key="block.id"
|
|
class="admin-block-editor__row group/block relative isolate rounded"
|
|
:class="{
|
|
'admin-block-editor__row--selected': selectedBlockId === block.id,
|
|
'admin-block-editor__row--range-selected': isBlockRangeRowSelected(index),
|
|
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
|
|
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
|
|
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
|
|
'admin-block-editor__row--callout': block.type === 'callout',
|
|
'admin-block-editor__row--menu-open z-30': visibleCommands.length && activeBlockId === block.id,
|
|
'admin-block-editor__row--text': isTextBlock(block),
|
|
'admin-block-editor__row--structure': !isTextBlock(block)
|
|
}"
|
|
:data-editor-block-id="block.id"
|
|
:data-block-index="index"
|
|
@dragover="updateBlockDropTarget($event, index)"
|
|
@drop="dropBlock($event, index)"
|
|
>
|
|
<button
|
|
class="admin-block-editor__handle absolute -left-9 bottom-0 top-0 z-10 flex w-5 cursor-grab items-stretch justify-center rounded opacity-0 outline-none transition-opacity duration-150 focus:opacity-100 active:cursor-grabbing"
|
|
type="button"
|
|
draggable="true"
|
|
aria-label="블록 이동 및 선택"
|
|
@click.stop="selectBlock(block)"
|
|
@keydown.delete="deleteSelectedBlock($event, index)"
|
|
@keydown.backspace="deleteSelectedBlock($event, index)"
|
|
@dragstart="startBlockDrag($event, block)"
|
|
@dragend="finishBlockDrag"
|
|
>
|
|
<span class="admin-block-editor__handle-container" aria-hidden="true">
|
|
<span class="admin-block-editor__handle-grabber"></span>
|
|
</span>
|
|
</button>
|
|
|
|
<span
|
|
v-if="block.type !== 'divider'"
|
|
class="admin-block-editor__range-lane absolute bottom-0 left-[-1.375rem] top-0 z-[8] w-[18px] touch-none select-none"
|
|
role="button"
|
|
tabindex="-1"
|
|
aria-label="블록 범위 선택: 드래그 또는 Shift+클릭"
|
|
title="블록 범위 선택: 드래그 또는 Shift+클릭"
|
|
@pointerdown="onBlockRangeLanePointerDown($event, index)"
|
|
/>
|
|
|
|
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider border-line">
|
|
|
|
<figure
|
|
v-else-if="block.type === 'image'"
|
|
class="admin-block-editor__media group"
|
|
:class="getImageWidthClass(block.width)"
|
|
tabindex="0"
|
|
@focus="activateBlock(block)"
|
|
@click="activateBlock(block)"
|
|
@keydown.enter="handleEnter($event, index)"
|
|
@keydown.backspace="handleBackspace($event, index)"
|
|
>
|
|
<div v-if="block.url" class="admin-block-editor__image-frame relative overflow-hidden rounded bg-surface">
|
|
<img class="admin-block-editor__image w-full object-cover" :src="block.url" :alt="block.alt">
|
|
<div class="admin-block-editor__media-toolbar absolute inset-x-3 top-3 flex flex-wrap items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 group-focus:opacity-100">
|
|
<button
|
|
class="admin-block-editor__media-option rounded bg-white/95 px-3 py-1 text-xs font-semibold text-ink shadow"
|
|
type="button"
|
|
@click="openMediaPicker(block)"
|
|
>
|
|
미디어 선택
|
|
</button>
|
|
<button
|
|
v-for="option in imageWidthOptions"
|
|
:key="option.value"
|
|
class="admin-block-editor__media-option rounded bg-white/95 px-3 py-1 text-xs font-semibold shadow"
|
|
:class="block.width === option.value ? 'text-ink ring-1 ring-ink' : 'text-muted'"
|
|
type="button"
|
|
@click="updateImageWidth(block, option.value)"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="admin-block-editor__upload grid gap-3 rounded border border-dashed border-line bg-surface px-6 py-14 text-center text-sm font-semibold text-muted">
|
|
<button class="admin-block-editor__media-select rounded border border-line bg-white px-3 py-2 text-ink" type="button" @click="openMediaPicker(block)">
|
|
미디어 선택
|
|
</button>
|
|
<label class="admin-block-editor__upload-label cursor-pointer rounded bg-[#15171a] px-3 py-2 text-white">
|
|
{{ isUploading(block.id) ? '업로드 중' : '새 이미지 업로드' }}
|
|
<input class="sr-only" type="file" accept="image/*" @change="handleImageUpload($event, block)">
|
|
</label>
|
|
</div>
|
|
<input
|
|
v-if="block.url"
|
|
v-model="block.alt"
|
|
class="admin-block-editor__caption mt-3 hidden w-full border-0 bg-transparent text-center text-sm text-muted outline-none placeholder:text-soft group-hover:block group-focus-within:block"
|
|
type="text"
|
|
placeholder="이미지 설명"
|
|
@input="emitContent"
|
|
>
|
|
</figure>
|
|
|
|
<figure
|
|
v-else-if="block.type === 'gallery'"
|
|
class="admin-block-editor__gallery group"
|
|
tabindex="0"
|
|
@focus="activateBlock(block)"
|
|
@click="activateBlock(block)"
|
|
@keydown.enter="handleEnter($event, index)"
|
|
@keydown.backspace="handleBackspace($event, index)"
|
|
>
|
|
<div
|
|
v-if="block.images.length"
|
|
class="admin-block-editor__gallery-grid grid gap-2"
|
|
:style="{ gridTemplateColumns: `repeat(${getGalleryColumnCount(block)}, minmax(0, 1fr))` }"
|
|
>
|
|
<div
|
|
v-for="(image, imageIndex) in block.images"
|
|
:key="`${block.id}-${image.url}`"
|
|
class="admin-block-editor__gallery-item group/item relative overflow-hidden rounded bg-surface"
|
|
:class="{
|
|
'opacity-50': draggingGalleryImage?.blockId === block.id && draggingGalleryImage?.imageIndex === imageIndex,
|
|
'admin-block-editor__gallery-item--drop-before': galleryDragTarget?.blockId === block.id && galleryDragTarget?.imageIndex === imageIndex && galleryDragTarget?.position === 'before',
|
|
'admin-block-editor__gallery-item--drop-after': galleryDragTarget?.blockId === block.id && galleryDragTarget?.imageIndex === imageIndex && galleryDragTarget?.position === 'after'
|
|
}"
|
|
draggable="true"
|
|
@dragstart.stop="startGalleryImageDrag($event, block, imageIndex)"
|
|
@dragover.prevent.stop="updateGalleryImageDropTarget($event, block, imageIndex)"
|
|
@drop.prevent.stop="dropGalleryImage($event, block, imageIndex)"
|
|
@dragend="finishGalleryImageDrag"
|
|
>
|
|
<img class="admin-block-editor__gallery-image aspect-[4/3] w-full object-cover" :src="image.url" :alt="image.alt">
|
|
<span class="admin-block-editor__gallery-drag-hint pointer-events-none absolute left-2 top-2 rounded bg-black/70 px-2 py-1 text-xs font-semibold text-white opacity-0 transition-opacity group-hover/item:opacity-100">
|
|
드래그
|
|
</span>
|
|
<button
|
|
class="admin-block-editor__gallery-remove absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-ink opacity-0 shadow transition-opacity group-hover/item:opacity-100"
|
|
type="button"
|
|
@click="removeGalleryImage(block, imageIndex)"
|
|
>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="admin-block-editor__gallery-actions mt-3 flex flex-wrap gap-2">
|
|
<button class="admin-block-editor__gallery-select rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="openMediaPicker(block)">
|
|
미디어 선택
|
|
</button>
|
|
<label class="admin-block-editor__gallery-upload inline-flex cursor-pointer rounded bg-[#15171a] px-3 py-2 text-sm font-semibold text-white">
|
|
{{ isUploading(block.id) ? '업로드 중' : block.images.length ? '새 이미지 추가' : '갤러리 이미지 업로드' }}
|
|
<input class="sr-only" type="file" accept="image/*" multiple @change="handleGalleryUpload($event, block)">
|
|
</label>
|
|
</div>
|
|
</figure>
|
|
|
|
<section
|
|
v-else-if="block.type === 'callout'"
|
|
class="admin-block-editor__callout-editor relative"
|
|
@focusin="activateBlock(block)"
|
|
@click="activateBlock(block)"
|
|
@keydown.enter="handleEnter($event, index)"
|
|
@keydown.backspace="handleBackspace($event, index)"
|
|
>
|
|
<ProseCallout
|
|
:emoji-enabled="false"
|
|
:background="block.calloutBackground"
|
|
>
|
|
<div class="flex items-start gap-2">
|
|
<button
|
|
v-if="block.calloutEmojiEnabled"
|
|
class="inline-flex size-9 shrink-0 items-center justify-center cursor-pointer rounded-md text-xl text-[#1f2328] hover:bg-[#7f8da1]/20"
|
|
type="button"
|
|
@click.stop="toggleCalloutEmojiPicker(block)"
|
|
>
|
|
{{ block.calloutEmoji || '💡' }}
|
|
</button>
|
|
<div
|
|
:ref="(element) => setBlockRef(element, index)"
|
|
class="admin-block-editor__callout-input w-full bg-transparent text-[15px] leading-8 text-[#1f2328] outline-none"
|
|
contenteditable="true"
|
|
spellcheck="true"
|
|
data-placeholder="콜아웃 텍스트 입력은 이렇게"
|
|
:data-show-placeholder="!block.text"
|
|
@focus="activateBlock(block)"
|
|
@paste="handleTextBlockPaste($event, index)"
|
|
@keydown="handleTextBlockKeydownRoot($event, index)"
|
|
@input="updateBlockText($event, index)"
|
|
@compositionstart="startTextComposition"
|
|
@compositionend="finishTextComposition($event, index)"
|
|
@keydown.enter="handleEnter($event, index)"
|
|
@keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')"
|
|
@keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')"
|
|
@keydown.backspace="handleBackspace($event, index)"
|
|
/>
|
|
</div>
|
|
</ProseCallout>
|
|
|
|
<div
|
|
v-if="activeCalloutBlock?.id === block.id"
|
|
class="admin-block-editor__callout-settings absolute left-full top-0 z-[70] ml-4 flex w-[320px] flex-col gap-3 rounded-lg bg-white p-6 shadow-lg"
|
|
>
|
|
<label class="flex w-full items-center justify-between">
|
|
<div class="text-sm font-medium text-ink">Emoji</div>
|
|
<button
|
|
class="relative inline-flex h-4 w-7 items-center rounded-full transition-colors"
|
|
:class="block.calloutEmojiEnabled ? 'bg-black' : 'bg-[#d0d7de]'"
|
|
type="button"
|
|
@click="toggleCalloutEmoji(block)"
|
|
>
|
|
<span
|
|
class="absolute size-3 rounded-full bg-white transition-transform"
|
|
:class="block.calloutEmojiEnabled ? 'translate-x-[12px]' : 'translate-x-[2px]'"
|
|
></span>
|
|
</button>
|
|
</label>
|
|
|
|
<div class="relative flex w-full items-center justify-between">
|
|
<div class="text-sm font-medium text-ink">Background</div>
|
|
<button
|
|
class="relative size-6 shrink-0 cursor-pointer rounded-full p-[2px]"
|
|
type="button"
|
|
@click="toggleCalloutColorPopover(block)"
|
|
>
|
|
<span
|
|
class="absolute inset-0 rounded-full"
|
|
style="background: conic-gradient(rgb(255,0,0), rgb(255,0,191), rgb(128,0,255), rgb(0,64,255), rgb(0,255,255), rgb(0,255,64), rgb(128,255,0), rgb(255,191,0), rgb(255,0,0)); mask: linear-gradient(#fff 0 0) content-box exclude, linear-gradient(#fff 0 0); padding:3px;"
|
|
></span>
|
|
<span
|
|
class="relative block size-full rounded-full border-2 border-white ring-1 ring-black/10"
|
|
:style="{
|
|
background: block.calloutBackground === 'gray' ? 'rgba(100,116,139,0.28)'
|
|
: block.calloutBackground === 'blue' ? 'rgba(59,130,246,0.3)'
|
|
: block.calloutBackground === 'green' ? 'rgba(34,197,94,0.3)'
|
|
: block.calloutBackground === 'yellow' ? 'rgba(245,158,11,0.34)'
|
|
: block.calloutBackground === 'red' ? 'rgba(239,68,68,0.3)'
|
|
: block.calloutBackground === 'purple' ? 'rgba(168,85,247,0.3)'
|
|
: 'rgba(236,72,153,0.3)'
|
|
}"
|
|
></span>
|
|
</button>
|
|
|
|
<div
|
|
v-if="calloutColorPopoverBlockId === block.id"
|
|
class="absolute -right-2 bottom-full mb-2 z-[90] rounded-lg bg-white px-3 py-2 shadow"
|
|
>
|
|
<ul class="flex items-center gap-1">
|
|
<li v-for="backgroundOption in calloutBackgroundOptions" :key="`color-pop-${block.id}-${backgroundOption.value}`">
|
|
<button
|
|
class="group relative flex size-6 cursor-pointer items-center justify-center rounded-full border-2"
|
|
:class="block.calloutBackground === backgroundOption.value ? 'border-[#22c55e]' : 'border-transparent'"
|
|
type="button"
|
|
:aria-label="backgroundOption.label"
|
|
@click="updateCalloutBackground(block, backgroundOption.value)"
|
|
>
|
|
<span
|
|
class="size-[1.4rem] rounded-full border border-black/10"
|
|
:style="{
|
|
background: backgroundOption.value === 'gray' ? 'rgba(100,116,139,0.28)'
|
|
: backgroundOption.value === 'blue' ? 'rgba(59,130,246,0.3)'
|
|
: backgroundOption.value === 'green' ? 'rgba(34,197,94,0.3)'
|
|
: backgroundOption.value === 'yellow' ? 'rgba(245,158,11,0.34)'
|
|
: backgroundOption.value === 'red' ? 'rgba(239,68,68,0.3)'
|
|
: backgroundOption.value === 'purple' ? 'rgba(168,85,247,0.3)'
|
|
: 'rgba(236,72,153,0.3)'
|
|
}"
|
|
></span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="block.calloutEmojiEnabled && calloutEmojiPickerBlockId === block.id" class="rounded-lg border border-line bg-white p-3">
|
|
<p class="mb-2 text-xs text-muted">이모지를 붙여넣거나 시스템 이모지 입력을 사용하세요</p>
|
|
<input
|
|
class="w-16 rounded-lg border border-line px-2 py-2 text-center text-2xl text-[#1f2328] outline-none"
|
|
type="text"
|
|
:value="block.calloutEmoji || ''"
|
|
maxlength="8"
|
|
spellcheck="false"
|
|
@input="updateCalloutEmojiFromInput(block, $event)"
|
|
@compositionstart="startCalloutEmojiComposition(block)"
|
|
@compositionend="finishCalloutEmojiComposition(block, $event)"
|
|
@blur="normalizeCalloutEmojiInput(block, $event)"
|
|
>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
v-else-if="block.type === 'toggle'"
|
|
class="admin-block-editor__toggle rounded border border-line bg-paper p-5"
|
|
@focusin="activateBlock(block)"
|
|
@click="activateBlock(block)"
|
|
@keydown.enter="handleEnter($event, index)"
|
|
@keydown.backspace="handleBackspace($event, index)"
|
|
>
|
|
<input
|
|
v-model="block.title"
|
|
class="admin-block-editor__toggle-title w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
|
|
type="text"
|
|
placeholder="토글 제목"
|
|
@input="updateStructuredBlock"
|
|
>
|
|
<textarea
|
|
v-model="block.text"
|
|
class="admin-block-editor__toggle-body mt-3 min-h-24 w-full resize-y border-0 bg-transparent text-sm leading-7 text-ink outline-none placeholder:text-soft"
|
|
placeholder="펼쳤을 때 보일 내용을 입력하세요"
|
|
@input="updateStructuredBlock"
|
|
@keydown.enter.stop
|
|
/>
|
|
</section>
|
|
|
|
<section
|
|
v-else-if="block.type === 'embed'"
|
|
class="admin-block-editor__embed rounded border border-dashed border-line bg-surface p-5"
|
|
@focusin="activateBlock(block)"
|
|
@click="activateBlock(block)"
|
|
@keydown.enter="handleEnter($event, index)"
|
|
@keydown.backspace="handleBackspace($event, index)"
|
|
>
|
|
<input
|
|
v-model="block.url"
|
|
class="admin-block-editor__embed-url w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
|
|
type="url"
|
|
placeholder="https://www.youtube.com/watch?v=..."
|
|
@input="updateStructuredBlock"
|
|
>
|
|
<p class="admin-block-editor__embed-help mt-2 text-xs text-muted">
|
|
YouTube 링크는 공개 화면에서 영상으로 표시됩니다.
|
|
</p>
|
|
</section>
|
|
|
|
<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)"
|
|
@paste="handleTextBlockPaste($event, index)"
|
|
@keydown="handleTextBlockKeydownRoot($event, index)"
|
|
@input="updateBlockText($event, index)"
|
|
@compositionstart="startTextComposition"
|
|
@compositionend="finishTextComposition($event, index)"
|
|
@keydown.enter="handleEnter($event, index)"
|
|
@keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')"
|
|
@keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')"
|
|
@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)"
|
|
@keydown.backspace="handleBackspace($event, index)"
|
|
>
|
|
구분선
|
|
</button>
|
|
|
|
<div
|
|
v-if="visibleCommands.length && activeBlockId === block.id"
|
|
class="admin-block-editor__slash-menu absolute left-0 z-20 w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded border border-line bg-white shadow-lg"
|
|
:class="slashMenuDirection === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'"
|
|
>
|
|
<button
|
|
v-for="(command, commandIndex) 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"
|
|
:class="commandIndex === highlightedCommandIndex ? 'admin-block-editor__slash-item--active bg-surface' : ''"
|
|
type="button"
|
|
@mousedown.prevent="applyCommand(command)"
|
|
>
|
|
<span class="admin-block-editor__slash-label text-sm font-semibold text-ink">{{ command.label }}</span>
|
|
<span class="admin-block-editor__slash-description text-xs text-muted">{{ command.description }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="isMediaPickerOpen"
|
|
class="admin-block-editor__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
@click.self="closeMediaPicker"
|
|
>
|
|
<section class="admin-block-editor__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
|
<div class="admin-block-editor__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
|
<h2 class="admin-block-editor__media-picker-title text-lg font-semibold">
|
|
미디어 선택
|
|
</h2>
|
|
<button class="admin-block-editor__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
|
닫기
|
|
</button>
|
|
</div>
|
|
<div class="admin-block-editor__media-picker-body max-h-[62vh] overflow-y-auto p-5">
|
|
<p v-if="isLoadingMedia" class="admin-block-editor__media-picker-loading text-sm text-muted">
|
|
미디어를 불러오는 중입니다.
|
|
</p>
|
|
<div v-else-if="mediaItems.length" class="admin-block-editor__media-picker-grid grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<button
|
|
v-for="item in mediaItems"
|
|
:key="item.url"
|
|
class="admin-block-editor__media-picker-item relative overflow-hidden border bg-white text-left transition-colors"
|
|
:class="mediaPickerTarget?.type === 'gallery' && isGalleryMediaSelected(item) ? 'border-[#15171a] ring-2 ring-[#15171a]/20' : 'border-line hover:border-[#8e9cac]'"
|
|
type="button"
|
|
@click="selectMediaItem(item)"
|
|
>
|
|
<img class="admin-block-editor__media-picker-image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
|
<span
|
|
v-if="mediaPickerTarget?.type === 'gallery' && isGalleryMediaSelected(item)"
|
|
class="admin-block-editor__media-picker-selected absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[#15171a] text-xs font-bold text-white"
|
|
aria-hidden="true"
|
|
>
|
|
✓
|
|
</span>
|
|
<span class="admin-block-editor__media-picker-name block truncate px-3 py-2 text-xs font-semibold text-ink">{{ item.name }}</span>
|
|
</button>
|
|
</div>
|
|
<p v-else class="admin-block-editor__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
|
|
선택할 미디어가 없습니다.
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-if="mediaPickerTarget?.type === 'gallery'"
|
|
class="admin-block-editor__media-picker-footer flex items-center justify-between gap-3 border-t border-line px-5 py-4"
|
|
>
|
|
<p class="admin-block-editor__media-picker-count text-sm text-muted">
|
|
{{ selectedGalleryMediaUrls.length }}개 선택됨
|
|
</p>
|
|
<div class="admin-block-editor__media-picker-actions flex gap-2">
|
|
<button class="admin-block-editor__media-picker-cancel rounded border border-line px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeMediaPicker">
|
|
취소
|
|
</button>
|
|
<button class="admin-block-editor__media-picker-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" type="button" @click="applyGalleryMediaSelection">
|
|
갤러리에 적용
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.admin-block-editor__block:empty[data-show-placeholder="true"]::before {
|
|
color: var(--site-soft);
|
|
content: attr(data-placeholder);
|
|
}
|
|
|
|
.admin-block-editor__block {
|
|
color: #1f2328;
|
|
}
|
|
|
|
.admin-block-editor__row::before {
|
|
position: absolute;
|
|
inset: -4px -10px;
|
|
z-index: -1;
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
content: "";
|
|
opacity: 0;
|
|
transform: scaleX(0.995);
|
|
transition:
|
|
background-color 160ms ease,
|
|
opacity 160ms ease,
|
|
transform 160ms ease;
|
|
}
|
|
|
|
.admin-block-editor__row::after {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 20;
|
|
height: 3px;
|
|
border-radius: 999px;
|
|
background: #2eb6ea;
|
|
box-shadow: 0 0 0 1px rgba(46, 182, 234, 0.18);
|
|
content: "";
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transform: scaleX(0.98);
|
|
transition:
|
|
opacity 120ms ease,
|
|
transform 120ms ease;
|
|
}
|
|
|
|
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover::before,
|
|
.admin-block-editor__row--selected::before {
|
|
background: #eff1f2;
|
|
opacity: 1;
|
|
transform: scaleX(1);
|
|
}
|
|
|
|
.admin-block-editor__row--callout:hover::before,
|
|
.admin-block-editor__row--callout.admin-block-editor__row--selected::before {
|
|
opacity: 0;
|
|
}
|
|
|
|
.admin-block-editor__row--callout.admin-block-editor__row--range-selected::before {
|
|
opacity: 1;
|
|
}
|
|
|
|
.admin-block-editor__row--range-selected::before {
|
|
z-index: -1;
|
|
border-radius: 8px;
|
|
background: rgba(46, 182, 234, 0.14);
|
|
opacity: 1;
|
|
transform: scaleX(1);
|
|
}
|
|
|
|
.admin-block-editor__range-lane {
|
|
cursor: crosshair;
|
|
}
|
|
|
|
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__range-lane {
|
|
border-radius: 4px;
|
|
background: rgba(46, 182, 234, 0.08);
|
|
}
|
|
|
|
.admin-block-editor__row--drop-before::after {
|
|
top: -18px;
|
|
opacity: 1;
|
|
transform: scaleX(1);
|
|
}
|
|
|
|
.admin-block-editor__row--drop-after::after {
|
|
bottom: -18px;
|
|
opacity: 1;
|
|
transform: scaleX(1);
|
|
}
|
|
|
|
.admin-block-editor__handle {
|
|
min-height: 32px;
|
|
}
|
|
|
|
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__handle {
|
|
opacity: 1;
|
|
}
|
|
|
|
.admin-block-editor__handle-container {
|
|
display: flex;
|
|
width: 16px;
|
|
height: 100%;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 6px 0;
|
|
}
|
|
|
|
.admin-block-editor__handle-grabber {
|
|
display: block;
|
|
width: 4px;
|
|
height: 20px;
|
|
border-radius: 999px;
|
|
background: #8d969f;
|
|
opacity: 0.72;
|
|
transition:
|
|
height 160ms ease,
|
|
background-color 160ms ease,
|
|
opacity 160ms ease;
|
|
}
|
|
|
|
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__handle-grabber,
|
|
.admin-block-editor__row--selected .admin-block-editor__handle-grabber,
|
|
.admin-block-editor__handle:focus .admin-block-editor__handle-grabber {
|
|
height: 100%;
|
|
background: #5d6670;
|
|
opacity: 1;
|
|
}
|
|
|
|
.admin-block-editor__row + .admin-block-editor__row--text {
|
|
margin-top: 32px;
|
|
}
|
|
|
|
.admin-block-editor__row + .admin-block-editor__row--structure {
|
|
margin-top: 32px;
|
|
}
|
|
|
|
.admin-block-editor__row--structure + .admin-block-editor__row--structure {
|
|
margin-top: 32px;
|
|
}
|
|
|
|
.admin-block-editor__code {
|
|
background: #15171a;
|
|
color: #f8fafc;
|
|
caret-color: #f8fafc;
|
|
}
|
|
|
|
.admin-block-editor__callout-input:empty[data-show-placeholder="true"]::before {
|
|
color: var(--site-soft);
|
|
content: attr(data-placeholder);
|
|
}
|
|
|
|
.admin-block-editor__gallery-item--drop-before::before,
|
|
.admin-block-editor__gallery-item--drop-after::before {
|
|
position: absolute;
|
|
top: 8px;
|
|
bottom: 8px;
|
|
z-index: 20;
|
|
width: 4px;
|
|
border-radius: 999px;
|
|
background: #2eb6ea;
|
|
box-shadow:
|
|
0 0 0 3px rgba(46, 182, 234, 0.16),
|
|
0 6px 18px rgba(46, 182, 234, 0.35);
|
|
content: "";
|
|
pointer-events: none;
|
|
}
|
|
|
|
.admin-block-editor__gallery-item--drop-before::before {
|
|
left: 0;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.admin-block-editor__gallery-item--drop-after::before {
|
|
right: 0;
|
|
transform: translateX(50%);
|
|
}
|
|
</style>
|