diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 52c8ff9..da32782 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -8,7 +8,9 @@ const props = defineProps({ const emit = defineEmits(['update:modelValue']) +const editorRootRef = ref(null) const textareaRef = ref(null) +const previewRef = ref(null) const gutterRef = ref(null) const activeMode = ref('write') /** 커서가 있는 논리 줄(0-based, `\\n` 기준) */ @@ -32,6 +34,84 @@ const markdownValue = computed({ set: (value) => emit('update:modelValue', value) }) +/** + * 이미지 마크다운 한 줄을 구조화한다. + * @param {string} line - 이미지 마크다운 줄 + * @returns {{ alt: string, url: string, width: string }|null} 이미지 정보 + */ +const parseImageMarkdownLine = (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' + } +} + +/** + * 현재 커서가 속한 이미지 또는 갤러리 블록 정보를 찾는다. + * @returns {{ type: 'image'|'gallery', startLine: number, endLine: number, images: Array<{ alt: string, url: string, width: string }> }|null} + */ +const activeMediaBlock = computed(() => { + const lines = (markdownValue.value || '').split('\n') + const currentLine = Math.min(activeLogicalLineIndex.value, lines.length - 1) + const activeImage = parseImageMarkdownLine(lines[currentLine] || '') + + if (activeImage) { + return { + type: 'image', + startLine: currentLine, + endLine: currentLine, + images: [activeImage] + } + } + + let galleryStart = -1 + + for (let index = currentLine; index >= 0; index -= 1) { + if ((lines[index] || '').trim() === ':::gallery') { + galleryStart = index + break + } + + if ((lines[index] || '').trim() === ':::') { + break + } + } + + if (galleryStart === -1) { + return null + } + + let galleryEnd = -1 + + for (let index = galleryStart + 1; index < lines.length; index += 1) { + if ((lines[index] || '').trim() === ':::') { + galleryEnd = index + break + } + } + + if (galleryEnd === -1 || currentLine > galleryEnd) { + return null + } + + return { + type: 'gallery', + startLine: galleryStart, + endLine: galleryEnd, + images: lines + .slice(galleryStart + 1, galleryEnd) + .map(parseImageMarkdownLine) + .filter(Boolean) + } +}) + /** * 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다. * @returns {number} @@ -92,9 +172,22 @@ watch(() => props.modelValue, () => { watch(activeMode, (mode) => { if (mode === 'write') { refreshCaretLogicalLine() + return } + + nextTick(() => { + previewRef.value?.focus() + }) }) +/** + * 작성 모드와 미리보기 모드를 전환한다. + * @returns {void} + */ +const toggleEditorMode = () => { + activeMode.value = activeMode.value === 'write' ? 'preview' : 'write' +} + onMounted(() => { /** * document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다. @@ -114,10 +207,32 @@ onMounted(() => { refreshCaretLogicalLine() } + /** + * 에디터 안에서 Cmd/Ctrl+E를 누르면 작성/미리보기 모드를 전환한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {void} + */ + const onDocumentKeydown = (event) => { + const root = editorRootRef.value + + if (!root || !root.contains(document.activeElement)) { + return + } + + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'e') { + return + } + + event.preventDefault() + toggleEditorMode() + } + document.addEventListener('selectionchange', onSelectionChange) + document.addEventListener('keydown', onDocumentKeydown) onBeforeUnmount(() => { document.removeEventListener('selectionchange', onSelectionChange) + document.removeEventListener('keydown', onDocumentKeydown) }) refreshCaretLogicalLine() @@ -292,6 +407,136 @@ const createImageMarkdown = (image) => { return `![${image.alt || ''}](${image.url})${width}` } +/** + * 지정 줄 범위를 새 줄 목록으로 교체한다. + * @param {number} startLine - 시작 줄 + * @param {number} endLine - 끝 줄 + * @param {string[]} replacementLines - 대체 줄 목록 + * @param {boolean} focusEditor - 교체 후 textarea에 포커스를 돌릴지 여부 + * @returns {void} + */ +const replaceLineRange = (startLine, endLine, replacementLines, focusEditor = true) => { + const lines = (markdownValue.value || '').split('\n') + const nextLines = [ + ...lines.slice(0, startLine), + ...replacementLines, + ...lines.slice(endLine + 1) + ] + + markdownValue.value = nextLines.join('\n') + + if (focusEditor) { + setTextareaSelection(nextLines.slice(0, startLine + replacementLines.length).join('\n').length) + } +} + +/** + * 현재 미디어 블록을 이미지 목록 기준으로 다시 작성한다. + * @param {Array<{ alt: string, url: string, width?: string }>} images - 이미지 목록 + * @returns {void} + */ +const replaceActiveMediaImages = (images) => { + const block = activeMediaBlock.value + + if (!block) { + return + } + + if (block.type === 'image') { + if (!images[0]) { + replaceLineRange(block.startLine, block.endLine, [], false) + return + } + + replaceLineRange(block.startLine, block.endLine, [createImageMarkdown(images[0])], false) + return + } + + if (!images.length) { + replaceLineRange(block.startLine, block.endLine, [], false) + return + } + + replaceLineRange(block.startLine, block.endLine, [ + ':::gallery', + ...images.map(createImageMarkdown), + ':::' + ], false) +} + +/** + * 현재 미디어 블록의 특정 이미지를 수정한다. + * @param {number} imageIndex - 이미지 인덱스 + * @param {Partial<{ alt: string, url: string, width: string }>} patch - 변경 값 + * @returns {void} + */ +const updateActiveMediaImage = (imageIndex, patch) => { + const block = activeMediaBlock.value + + if (!block) { + return + } + + const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image) + replaceActiveMediaImages(images) +} + +/** + * 현재 갤러리 이미지 순서를 바꾼다. + * @param {number} imageIndex - 이동할 이미지 인덱스 + * @param {-1|1} direction - 이동 방향 + * @returns {void} + */ +const moveActiveGalleryImage = (imageIndex, direction) => { + const block = activeMediaBlock.value + + if (!block || block.type !== 'gallery') { + return + } + + const nextIndex = imageIndex + direction + + if (nextIndex < 0 || nextIndex >= block.images.length) { + return + } + + const images = [...block.images] + const [target] = images.splice(imageIndex, 1) + images.splice(nextIndex, 0, target) + replaceActiveMediaImages(images) +} + +/** + * 현재 미디어 블록에서 이미지를 삭제한다. + * @param {number} imageIndex - 삭제할 이미지 인덱스 + * @returns {void} + */ +const removeActiveMediaImage = (imageIndex) => { + const block = activeMediaBlock.value + + if (!block) { + return + } + + replaceActiveMediaImages(block.images.filter((_, index) => index !== imageIndex)) +} + +/** + * 현재 갤러리에 이미지를 추가한다. + * @param {Array<{ url: string, alt?: string, width?: string }>} images - 추가 이미지 목록 + * @returns {void} + */ +const appendImagesToActiveGallery = (images) => { + const block = activeMediaBlock.value + + if (!block || block.type !== 'gallery') { + insertGallery(images) + return + } + + replaceActiveMediaImages([...block.images, ...images]) +} + /** * 이미지 마크다운을 삽입한다. * @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보 @@ -330,7 +575,7 @@ const fetchMediaItems = async () => { /** * 미디어 선택 창을 연다. - * @param {'image'|'gallery'} target - 삽입 대상 + * @param {'image'|'gallery'|'active-gallery'} target - 삽입 대상 * @returns {Promise} */ const openMediaPicker = async (target) => { @@ -383,6 +628,11 @@ const applyMediaSelection = () => { width: selectedImageWidth.value }) } + } else if (mediaPickerTarget.value === 'active-gallery') { + appendImagesToActiveGallery(selectedItems.map((item) => ({ + url: item.url, + alt: item.name || '' + }))) } else { insertGallery(selectedItems.map((item) => ({ url: item.url, @@ -445,6 +695,131 @@ const uploadAndInsert = async (files, target = 'image') => { } } +/** + * 인라인 HTML 노드를 마크다운 문자열로 변환한다. + * @param {Node} node - HTML 노드 + * @returns {string} 마크다운 문자열 + */ +const convertHtmlInlineNodeToMarkdown = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || '' + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return '' + } + + const element = /** @type {HTMLElement} */ (node) + const tagName = element.tagName.toLowerCase() + const childText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('') + + if (tagName === 'strong' || tagName === 'b') { + return `**${childText}**` + } + + if (tagName === 'em' || tagName === 'i') { + return `*${childText}*` + } + + if (tagName === 'code') { + return `\`${childText}\`` + } + + if (tagName === 'a') { + const href = element.getAttribute('href') + return href ? `[${childText || href}](${href})` : childText + } + + if (tagName === 'img') { + const src = element.getAttribute('src') + const alt = element.getAttribute('alt') || '' + return src ? `![${alt}](${src})` : '' + } + + if (tagName === 'br') { + return '\n' + } + + return childText +} + +/** + * HTML 블록 노드를 마크다운 문자열로 변환한다. + * @param {Node} node - HTML 노드 + * @param {number} listIndex - 순서 목록 번호 + * @returns {string} 마크다운 문자열 + */ +const convertHtmlBlockNodeToMarkdown = (node, listIndex = 1) => { + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent || '').trim() + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return '' + } + + const element = /** @type {HTMLElement} */ (node) + const tagName = element.tagName.toLowerCase() + const inlineText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim() + + if (/^h[1-6]$/.test(tagName)) { + return `${'#'.repeat(Number(tagName.slice(1)))} ${inlineText}` + } + + if (tagName === 'p') { + return inlineText + } + + if (tagName === 'blockquote') { + return inlineText.split('\n').map((line) => `> ${line}`).join('\n') + } + + if (tagName === 'pre') { + return `\`\`\`\n${element.textContent?.trim() || ''}\n\`\`\`` + } + + if (tagName === 'li') { + return `${listIndex}. ${inlineText}` + } + + if (tagName === 'ul' || tagName === 'ol') { + return Array.from(element.children) + .filter((child) => child.tagName.toLowerCase() === 'li') + .map((child, index) => { + const marker = tagName === 'ol' ? `${index + 1}.` : '-' + const text = Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim() + return `${marker} ${text}` + }) + .join('\n') + } + + if (tagName === 'div' || tagName === 'section' || tagName === 'article') { + const childBlocks = Array.from(element.childNodes) + .map(convertHtmlBlockNodeToMarkdown) + .filter(Boolean) + + return childBlocks.length ? childBlocks.join('\n\n') : inlineText + } + + return inlineText +} + +/** + * 클립보드 HTML을 마크다운 문서 조각으로 변환한다. + * @param {string} html - HTML 문자열 + * @returns {string} 마크다운 문자열 + */ +const convertHtmlToMarkdown = (html) => { + const document = new DOMParser().parseFromString(html, 'text/html') + + return Array.from(document.body.childNodes) + .map(convertHtmlBlockNodeToMarkdown) + .filter(Boolean) + .join('\n\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + /** * 파일 입력 변경 처리 * @param {Event} event - 파일 입력 이벤트 @@ -465,6 +840,17 @@ const handlePaste = async (event) => { const imageFiles = Array.from(event.clipboardData?.files || []).filter((file) => file.type.startsWith('image/')) if (!imageFiles.length) { + const html = event.clipboardData?.getData('text/html') + + if (html) { + const markdown = convertHtmlToMarkdown(html) + + if (markdown) { + event.preventDefault() + replaceSelection(markdown) + } + } + return } @@ -506,15 +892,12 @@ const handleKeydown = (event) => { } else if (key === 'i') { event.preventDefault() wrapInline('*', '*', '기울임') - } else if (key === 'e') { - event.preventDefault() - wrapInline('`', '`', 'code') } }