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

4171 lines
125 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
import {
getImageDefaultAltLabel,
isImageUrl,
parseImageMarkdownLine,
serializeImageMarkdown
} from '../../lib/markdown-image.js'
import {
convertHtmlToMarkdown,
formatInlineColorMarkdown,
readEditableTextFromElement
} from '../../lib/markdown-inline.js'
import {
getMarkdownLineBodyPrefixLength,
getSelectionOffsetInElement
} from '../../lib/markdown-live-selection.js'
import {
filterSlashCommands,
getSlashCommandFocusTarget,
parseSlashInput,
resolveSlashCommand
} from '../../lib/markdown-slash-commands.js'
import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
import {
getPostBodyPreviewThumbnailUrl,
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl,
withGeneratedImageCacheVersion
} from '../../lib/post-card-thumbnail-url.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import {
buildDefaultUploadSizeLimits,
DEFAULT_UPLOAD_MAX_FILE_COUNT,
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
formatUploadSizeLimit,
getUploadFilesTotalSize,
getMaxUploadBytesForKind,
getUploadKind,
normalizeUploadMaxFileCount,
normalizeUploadMaxTotalSize
} from '../../lib/upload-size-limit.js'
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
const { toast, showToast } = useAdminToast()
const runtimeConfig = useRuntimeConfig()
const { data: uploadSettings } = useFetch('/admin/api/settings', {
key: 'admin-markdown-editor-upload-settings'
})
const uploadSizeLimits = computed(() => buildDefaultUploadSizeLimits({
image: Number(runtimeConfig.public.maxFileSize || 10485760),
video: Number(runtimeConfig.public.maxVideoFileSize || 209715200),
audio: Number(runtimeConfig.public.maxAudioFileSize || 52428800),
document: Number(runtimeConfig.public.maxDocumentFileSize || 52428800)
}))
const uploadMaxFileCount = computed(() => normalizeUploadMaxFileCount(uploadSettings.value?.uploadMaxFileCount || DEFAULT_UPLOAD_MAX_FILE_COUNT))
const uploadMaxTotalSize = computed(() => normalizeUploadMaxTotalSize(uploadSettings.value?.uploadMaxTotalSize || DEFAULT_UPLOAD_MAX_TOTAL_SIZE))
const props = defineProps({
modelValue: {
type: [String, Array, Object],
default: ''
},
/** 작성/미리보기 토글 버튼 Teleport 대상(AdminPostForm 헤더) */
modeToggleTeleportTo: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'block-panel'])
const activeMode = defineModel('editorMode', {
type: String,
default: 'write'
})
const editorRootRef = ref(null)
const textareaRef = ref(null)
const previewRef = ref(null)
const previewRendererRef = ref(null)
const gutterRef = ref(null)
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
const activeLogicalLineIndex = ref(0)
/** textarea 자동 줄바꿈을 반영한 논리 줄별 표시 높이(px) */
const gutterLineHeights = ref([])
/** 라이브 모드에서 소스 모드로 돌아올 때 복원할 줄(0-based) */
const pendingWriteFocusLine = ref(null)
/** 소스 모드에서 라이브 모드로 넘어갈 때 복원할 줄·오프셋 */
const pendingPreviewFocus = ref(null)
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
const isUploading = ref(false)
const mediaPickerTarget = ref('image')
const activeMediaPickerTab = ref('upload')
const mediaSearchQuery = ref('')
const {
displayValue: mediaSearchInput,
onInput: onMediaSearchInput,
onCompositionEnd: onMediaSearchCompositionEnd
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
const mediaPickerPage = ref(1)
const mediaPickerPageSize = 50
const mediaPickerPagination = ref({
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
})
const selectedMediaUrls = ref([])
const selectedMediaItemMap = ref({})
const lastSelectionState = ref({
start: 0,
end: 0
})
/** @type {import('vue').Ref<number|null>} 슬래시 명령이 입력된 줄 */
const liveSlashSourceLine = ref(null)
/** @type {import('vue').Ref<string>} 슬래시 검색어 */
const liveSlashQuery = ref('')
/** @type {import('vue').Ref<number>} 슬래시 메뉴 하이라이트 인덱스 */
const liveSlashHighlightedIndex = ref(0)
/** @type {import('vue').Ref<{ top: number, left: number }>} 슬래시 메뉴 위치 */
const liveSlashMenuPosition = ref({ top: 0, left: 0 })
/** @type {import('vue').Ref<HTMLElement|null>} 슬래시 메뉴 DOM */
const liveSlashMenuRef = ref(null)
/** 슬래시 메뉴 예상 높이(px) — 렌더 전 위치 계산용 */
const LIVE_SLASH_MENU_ESTIMATED_HEIGHT = 280
/** @type {import('vue').Ref<number|null>} 슬래시 후 미디어 삽입 줄 */
const liveSlashInsertLine = ref(null)
/** @type {import('vue').Ref<Set<number>>} ESC로 슬래시 메뉴만 닫은 줄(0-based) */
const liveSlashSuppressedLines = ref(new Set())
/** 키보드로 슬래시 메뉴를 탐색 중일 때 mouseenter 하이라이트 무시 */
const liveSlashKeyboardNav = ref(false)
let liveSlashKeyboardNavTimer = null
const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])
const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
/** 되돌리기 히스토리 최대 보관 개수 */
const EDITOR_UNDO_LIMIT = 100
/** @type {import('vue').Ref<Array<string|{ value: string, selectionStart: number, selectionEnd: number }>>} 본문 되돌리기 스택 */
const editorUndoStack = ref([])
/** @type {import('vue').Ref<Array<string|{ value: string, selectionStart: number, selectionEnd: number }>>} 본문 다시 실행 스택 */
const editorRedoStack = ref([])
/** 히스토리 복원 중 변경 기록을 막는다. */
const isRestoringEditorHistory = ref(false)
/** 마지막 히스토리 기록 시각 */
let lastEditorHistoryRecordAt = 0
const mediaPickerAccept = computed(() => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return 'image/*'
}
if (mediaPickerTarget.value === 'video') {
return 'video/*'
}
if (mediaPickerTarget.value === 'audio') {
return 'audio/*'
}
return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
})
const mediaPickerKind = computed(() => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return 'image'
}
if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
return mediaPickerTarget.value
}
return 'all'
})
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
const markdownValue = computed({
get: () => normalizeMarkdownContent(props.modelValue),
set: (value) => emit('update:modelValue', value)
})
/** textarea 포커스·블록 패널 상호작용 */
const isTextareaFocused = ref(false)
const isBlockPanelEngaged = ref(false)
/** 한글 등 IME 조합 입력 중 여부 */
const isTextComposing = ref(false)
/** 조합 입력 중 패널 닫힘을 방지하기 위한 마지막 블록 컨텍스트 */
const lastStableBlockContext = ref(null)
/** @type {import('vue').Ref<{ kind: 'inline-color', mode: 'source'|'live', start?: number, end?: number, sourceLine?: number, startOffset?: number, endOffset?: number, bodyText?: string, text: string }|null>} 선택 텍스트 색상 패널 컨텍스트 */
const inlineColorSelectionContext = ref(null)
let blockPanelFocusTimer = null
/**
* 현재 포커스가 블록 설정 패널 안에 있는지 확인한다.
* @returns {boolean}
*/
const isFocusInBlockPanel = () => Boolean(
typeof document !== 'undefined'
&& document.activeElement?.closest?.('.admin-editor-block-panel')
)
/**
* 현재 포커스가 미디어 선택 모달 안에 있는지 확인한다.
* @returns {boolean}
*/
const isFocusInMediaPicker = () => Boolean(
typeof document !== 'undefined'
&& document.activeElement?.closest?.('.admin-markdown-editor__media-modal')
)
/**
* 블록 패널 편집 중 상태를 유지한다.
* @returns {void}
*/
const ensureBlockPanelEngaged = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = true
syncBlockPanelState()
}
/**
* 블록 설정 패널을 닫는다.
* @returns {void}
*/
const closeBlockPanel = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = false
isTextareaFocused.value = false
inlineColorSelectionContext.value = null
syncBlockPanelState()
}
/**
* 오프셋이 속한 마크다운 줄 번호를 반환한다.
* @param {string} value - 마크다운 본문
* @param {number} offset - 문자 오프셋
* @returns {number} 줄 번호
*/
const getLineIndexFromOffset = (value, offset) => {
const safeOffset = Math.max(0, Math.min(String(value || '').length, offset))
return String(value || '').slice(0, safeOffset).split('\n').length - 1
}
/**
* 현재 줄이 인라인 색상 적용 대상인지 확인한다.
* @param {number} lineIndex - 줄 번호
* @returns {boolean} 적용 가능 여부
*/
const canApplyInlineColorAtLine = (lineIndex) => {
const context = resolveActiveBlockContext(markdownValue.value, lineIndex)
return context?.kind !== 'code'
}
/**
* 소스 모드 textarea 선택 범위를 색상 패널 컨텍스트로 변환한다.
* @returns {{ kind: 'inline-color', mode: 'source', start: number, end: number, text: string }|null} 선택 컨텍스트
*/
const getSourceInlineColorSelectionContext = () => {
const textarea = textareaRef.value
if (activeMode.value !== 'write' || !textarea || document.activeElement !== textarea) {
return null
}
const start = Math.min(textarea.selectionStart, textarea.selectionEnd)
const end = Math.max(textarea.selectionStart, textarea.selectionEnd)
if (start === end) {
return null
}
const value = markdownValue.value || ''
const selectedText = value.slice(start, end)
if (!selectedText.trim() || selectedText.includes('\n')) {
return null
}
const startLine = getLineIndexFromOffset(value, start)
const endLine = getLineIndexFromOffset(value, end)
if (startLine !== endLine || !canApplyInlineColorAtLine(startLine)) {
return null
}
return {
kind: 'inline-color',
mode: 'source',
start,
end,
text: selectedText
}
}
/**
* DOM 노드가 속한 라이브 편집 요소를 반환한다.
* @param {Node|null} node - 선택 노드
* @returns {HTMLElement|null} 편집 요소
*/
const getEditableElementFromSelectionNode = (node) => {
if (!node) {
return null
}
const element = node instanceof Element ? node : node.parentElement
const editable = element?.closest?.('[contenteditable="true"][data-source-line]')
return editable instanceof HTMLElement ? editable : null
}
/**
* 라이브 모드 선택 범위를 색상 패널 컨텍스트로 변환한다.
* @returns {{ kind: 'inline-color', mode: 'live', sourceLine: number, startOffset: number, endOffset: number, bodyText: string, text: string }|null} 선택 컨텍스트
*/
const getLiveInlineColorSelectionContext = () => {
if (activeMode.value !== 'preview' || !import.meta.client) {
return null
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return null
}
const range = selection.getRangeAt(0)
const startEditable = getEditableElementFromSelectionNode(range.startContainer)
const endEditable = getEditableElementFromSelectionNode(range.endContainer)
if (!startEditable || startEditable !== endEditable) {
return null
}
const sourceLine = Number(startEditable.getAttribute('data-source-line'))
const sourceLineEnd = Number(startEditable.getAttribute('data-source-line-end') || sourceLine)
if (!Number.isInteger(sourceLine) || sourceLine !== sourceLineEnd || !canApplyInlineColorAtLine(sourceLine)) {
return null
}
const bodyText = readEditableTextFromElement(startEditable, { trimEnd: false })
const startOffset = getSelectionOffsetInElement(startEditable, range, 'start')
const endOffset = getSelectionOffsetInElement(startEditable, range, 'end')
const safeStart = Math.max(0, Math.min(startOffset, endOffset))
const safeEnd = Math.min(bodyText.length, Math.max(startOffset, endOffset))
const selectedText = bodyText.slice(safeStart, safeEnd)
if (!selectedText.trim()) {
return null
}
return {
kind: 'inline-color',
mode: 'live',
sourceLine,
startOffset: safeStart,
endOffset: safeEnd,
bodyText,
text: selectedText
}
}
/**
* 현재 선택 범위를 기준으로 텍스트 색상 패널 컨텍스트를 갱신한다.
* @returns {void}
*/
const syncInlineColorSelectionContext = () => {
if (isFocusInBlockPanel() || isMediaPickerOpen.value) {
return
}
const nextContext = getSourceInlineColorSelectionContext() || getLiveInlineColorSelectionContext()
inlineColorSelectionContext.value = nextContext
if (nextContext) {
isBlockPanelEngaged.value = true
}
}
/**
* 커서 줄 기준 활성 블록(이미지·갤러리·임베드·인용)
* @returns {Object|null}
*/
const activeBlockContext = computed(() => resolveActiveBlockContext(
markdownValue.value,
activeLogicalLineIndex.value
))
/** @deprecated 내부 호환 alias */
const activeMediaBlock = activeBlockContext
/**
* IME 조합 중에는 일시적으로 블록 판별이 비어도 직전 패널을 유지한다.
* @returns {Object|null} 패널 표시용 블록 컨텍스트
*/
const visibleBlockContext = computed(() => {
if (inlineColorSelectionContext.value) {
return null
}
if (activeBlockContext.value) {
return activeBlockContext.value
}
return isTextComposing.value ? lastStableBlockContext.value : null
})
/**
* 오른쪽 설정 패널에 표시할 현재 컨텍스트를 반환한다.
* @returns {Object|null} 패널 컨텍스트
*/
const activePanelContext = computed(() => inlineColorSelectionContext.value || visibleBlockContext.value)
/**
* 블록 설정 패널 표시 여부
* @returns {boolean}
*/
const isBlockPanelVisible = computed(() => (activeMode.value === 'write' || isBlockPanelEngaged.value)
&& Boolean(activePanelContext.value)
&& (isTextareaFocused.value || isBlockPanelEngaged.value))
/**
* 부모(글 설정 사이드바 오버레이)에 패널 상태를 전달한다.
* @returns {void}
*/
const syncBlockPanelState = () => {
emit('block-panel', {
open: isBlockPanelVisible.value,
panel: activePanelContext.value
})
}
/**
* 라이브 모드 포커스 줄을 기준으로 블록 패널을 동기화한다.
* @param {number} sourceLine - 원본 마크다운 줄(0-based)
* @returns {void}
*/
const handleLiveLineFocus = async (sourceLine) => {
const nextLine = Number(sourceLine)
if (!Number.isInteger(nextLine) || nextLine < 0) {
return
}
activeLogicalLineIndex.value = nextLine
isTextareaFocused.value = false
isTextComposing.value = false
await nextTick()
if (activeBlockContext.value) {
lastStableBlockContext.value = activeBlockContext.value
isBlockPanelEngaged.value = true
} else {
isBlockPanelEngaged.value = false
}
syncBlockPanelState()
}
/**
* 라이브 모드 포커스가 블록·패널 밖으로 나가면 패널을 닫는다.
* @returns {void}
*/
const handleLiveLineBlur = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
isBlockPanelEngaged.value = false
syncBlockPanelState()
}, 0)
}
watch(activeBlockContext, (context) => {
if (context) {
lastStableBlockContext.value = context
}
}, { deep: true, immediate: true })
watch([isBlockPanelVisible, activePanelContext], syncBlockPanelState, { deep: true })
/**
* 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
* @returns {number}
*/
const gutterLineCount = computed(() => {
const raw = markdownValue.value ?? ''
const n = raw.split('\n').length
return Math.max(1, n)
})
/**
* CSS px 값을 숫자로 변환한다.
* @param {string} value - CSS 값
* @param {number} fallback - 변환 실패 시 기본값
* @returns {number} px 숫자
*/
const parseCssPixelValue = (value, fallback) => {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : fallback
}
/**
* 소스 textarea의 줄 시작 오프셋을 반환한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {number} 문자 오프셋
*/
const getLineStartOffset = (lineIndex) => {
const lines = (markdownValue.value ?? '').split('\n')
const safeLineIndex = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1))
return lines.slice(0, safeLineIndex).join('\n').length + (safeLineIndex > 0 ? 1 : 0)
}
/**
* 문자 오프셋이 속한 소스 줄 번호를 반환한다.
* @param {number} offset - 문자 오프셋
* @returns {number} 줄 번호(0-based)
*/
const getLineIndexAtOffset = (offset) => {
const value = markdownValue.value ?? ''
const safeOffset = Math.min(Math.max(0, offset), value.length)
return Math.max(0, value.slice(0, safeOffset).split('\n').length - 1)
}
/**
* 닫는 fenced 줄에서 대응하는 여는 줄을 찾는다.
* @param {string[]} lines - 마크다운 줄
* @param {number} closingLine - 닫는 줄 번호
* @param {(line: string) => boolean} isOpeningLine - 여는 줄 판별
* @returns {number|null} 여는 줄 번호
*/
const findPreviousFencedOpeningLine = (lines, closingLine, isOpeningLine) => {
for (let index = closingLine - 1; index >= 0; index -= 1) {
const trimmed = String(lines[index] ?? '').trim()
if (isOpeningLine(trimmed)) {
return index
}
}
return null
}
/**
* 여는 fenced 줄에서 닫는 줄을 찾는다.
* @param {string[]} lines - 마크다운 줄
* @param {number} openingLine - 여는 줄 번호
* @param {string} closingMarker - 닫는 표식
* @returns {number|null} 닫는 줄 번호
*/
const findNextFencedClosingLine = (lines, openingLine, closingMarker) => {
for (let index = openingLine + 1; index < lines.length; index += 1) {
if (String(lines[index] ?? '').trim() === closingMarker) {
return index
}
}
return null
}
/**
* 지정 줄이 속한 코드 펜스 범위를 찾는다.
* @param {string[]} lines - 마크다운 줄
* @param {number} targetLine - 확인할 줄 번호
* @returns {{ openingLine: number, closingLine: number|null }|null} 코드 펜스 범위
*/
const findCodeFenceRangeAtLine = (lines, targetLine) => {
for (let index = 0; index < lines.length; index += 1) {
if (!String(lines[index] ?? '').trim().startsWith('```')) {
continue
}
const closingLine = findNextFencedClosingLine(lines, index, '```')
if (targetLine === index || (closingLine !== null && targetLine >= index && targetLine <= closingLine)) {
return { openingLine: index, closingLine }
}
if (closingLine !== null) {
index = closingLine
}
}
return null
}
/**
* 지정 줄이 코드 펜스 본문 줄인지 확인한다.
* @param {string[]} lines - 마크다운 줄 목록
* @param {number} lineIndex - 줄 번호
* @returns {boolean}
*/
const isCodeFenceBodyLine = (lines, lineIndex) => {
for (let index = 0; index <= lineIndex; index += 1) {
if (!String(lines[index] ?? '').trim().startsWith('```')) {
continue
}
const closingLine = findNextFencedClosingLine(lines, index, '```')
if (lineIndex > index && (closingLine === null || lineIndex < closingLine)) {
return true
}
if (closingLine !== null) {
index = closingLine
}
}
return false
}
/**
* 에디터 히스토리 항목을 만든다.
* @param {string} value - 저장할 본문
* @returns {{ value: string, selectionStart: number, selectionEnd: number }} 히스토리 항목
*/
const createEditorHistoryEntry = (value) => {
const safeValue = String(value ?? '')
return {
value: safeValue,
selectionStart: Math.min(lastSelectionState.value.start, safeValue.length),
selectionEnd: Math.min(lastSelectionState.value.end, safeValue.length)
}
}
/**
* 히스토리 항목의 본문 값을 반환한다.
* @param {string|{ value?: string }} entry - 히스토리 항목
* @returns {string|null} 본문 값
*/
const getEditorHistoryEntryValue = (entry) => {
if (typeof entry === 'string') {
return entry
}
if (entry && typeof entry.value === 'string') {
return entry.value
}
return null
}
/**
* 히스토리 항목의 선택 위치를 반환한다.
* @param {string|{ value?: string, selectionStart?: number, selectionEnd?: number }} entry - 히스토리 항목
* @param {string} value - 복원된 본문
* @returns {{ start: number, end: number }|null} 선택 위치
*/
const getEditorHistoryEntrySelection = (entry, value) => {
if (!entry || typeof entry === 'string') {
return null
}
const length = String(value ?? '').length
const start = Number(entry.selectionStart)
const end = Number(entry.selectionEnd)
if (!Number.isFinite(start) || !Number.isFinite(end)) {
return null
}
return {
start: Math.min(Math.max(0, start), length),
end: Math.min(Math.max(0, end), length)
}
}
/**
* 현재 선택 위치에 맞춰 에디터 포커스를 복원한다.
* @param {{ start: number, end: number }|null} selection - 복원할 선택 영역
* @returns {void}
*/
const restoreEditorFocusAfterHistory = (selection = null) => {
const focusLine = selection
? getLineIndexAtOffset(selection.start)
: activeLogicalLineIndex.value
if (activeMode.value === 'write') {
if (selection) {
setTextareaSelection(selection.start, selection.end, { preserveScroll: true })
return
}
focusTextareaAtLine(focusLine)
return
}
nextTick(() => {
const offset = selection
? Math.max(0, selection.start - getLineStartOffset(focusLine))
: 0
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'auto', offset, 'center')
})
}
/**
* 에디터 히스토리 항목을 기록한다.
* @param {string} previousValue - 변경 전 본문
* @returns {void}
*/
const recordEditorHistory = (previousValue) => {
if (isRestoringEditorHistory.value) {
return
}
const value = String(previousValue ?? '')
const now = Date.now()
const shouldGroup = now - lastEditorHistoryRecordAt < 900
if (!shouldGroup || !editorUndoStack.value.length) {
editorUndoStack.value = [...editorUndoStack.value, createEditorHistoryEntry(value)].slice(-EDITOR_UNDO_LIMIT)
}
editorRedoStack.value = []
lastEditorHistoryRecordAt = now
}
/**
* 에디터 본문 히스토리를 복원한다.
* @param {'undo'|'redo'} direction - 복원 방향
* @returns {void}
*/
const restoreEditorHistory = (direction) => {
const sourceStack = direction === 'undo' ? editorUndoStack : editorRedoStack
const targetStack = direction === 'undo' ? editorRedoStack : editorUndoStack
const nextEntry = sourceStack.value[sourceStack.value.length - 1]
const nextValue = getEditorHistoryEntryValue(nextEntry)
if (typeof nextValue !== 'string') {
return
}
sourceStack.value = sourceStack.value.slice(0, -1)
targetStack.value = [...targetStack.value, createEditorHistoryEntry(markdownValue.value ?? '')].slice(-EDITOR_UNDO_LIMIT)
isRestoringEditorHistory.value = true
markdownValue.value = nextValue
lastEditorHistoryRecordAt = 0
nextTick(() => {
isRestoringEditorHistory.value = false
restoreEditorFocusAfterHistory(getEditorHistoryEntrySelection(nextEntry, nextValue))
})
}
/**
* 라이브 모드에 실제로 존재하는 편집 줄로 포커스 대상을 보정한다.
* @param {number} line - 원본 줄 번호
* @param {number} offset - 원본 줄 내부 오프셋
* @returns {{ line: number, offset: number }} 라이브 포커스 대상
*/
const resolvePreviewFocusPosition = (line, offset) => {
const lines = (markdownValue.value ?? '').split('\n')
const safeLine = Math.min(Math.max(0, line), Math.max(0, lines.length - 1))
const trimmed = String(lines[safeLine] ?? '').trim()
if (trimmed.startsWith('```')) {
const codeFenceRange = findCodeFenceRangeAtLine(lines, safeLine)
if (!codeFenceRange || codeFenceRange.closingLine === null) {
return { line: safeLine, offset }
}
const focusLine = Math.min(
Math.max(codeFenceRange.openingLine + 1, safeLine),
Math.max(codeFenceRange.openingLine + 1, codeFenceRange.closingLine - 1)
)
return { line: focusLine, offset: safeLine === codeFenceRange.openingLine ? 0 : offset }
}
if (trimmed.startsWith(':::callout') || trimmed.startsWith(':::toggle')) {
return { line: safeLine + 1, offset: 0 }
}
if (trimmed === ':::') {
const previousFencedOpening = findPreviousFencedOpeningLine(
lines,
safeLine,
(candidate) => candidate.startsWith(':::callout') || candidate.startsWith(':::toggle')
)
if (previousFencedOpening !== null) {
return { line: Math.max(previousFencedOpening + 1, safeLine - 1), offset }
}
}
if (/^>\s*(?:\[!bg=|\{bg=)/.test(trimmed) && String(lines[safeLine + 1] ?? '').trim().startsWith('>')) {
return { line: safeLine + 1, offset: 0 }
}
return { line: safeLine, offset }
}
/**
* 현재 textarea 선택 위치를 라이브 모드 포커스 대상으로 저장한다.
* @returns {void}
*/
const rememberWritePositionForPreview = () => {
const textarea = textareaRef.value
const value = markdownValue.value ?? ''
const start = textarea
? Math.min(textarea.selectionStart, value.length)
: Math.min(lastSelectionState.value.start, value.length)
const line = getLineIndexAtOffset(start)
const offset = Math.max(0, start - getLineStartOffset(line))
const focusPosition = resolvePreviewFocusPosition(line, offset)
pendingPreviewFocus.value = {
line: focusPosition.line,
offset: focusPosition.offset
}
}
/**
* textarea와 동일한 폭·폰트로 각 논리 줄의 실제 wrap 높이를 측정한다.
* @returns {void}
*/
const syncGutterLineHeights = () => {
if (!import.meta.client) {
return
}
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) {
gutterLineHeights.value = []
return
}
const style = window.getComputedStyle(textarea)
const fallbackLineHeight = parseCssPixelValue(style.lineHeight, 28)
const paddingLeft = parseCssPixelValue(style.paddingLeft, 0)
const paddingRight = parseCssPixelValue(style.paddingRight, 0)
const contentWidth = Math.max(1, textarea.clientWidth - paddingLeft - paddingRight)
const mirror = document.createElement('div')
mirror.style.position = 'absolute'
mirror.style.left = '-10000px'
mirror.style.top = '0'
mirror.style.visibility = 'hidden'
mirror.style.pointerEvents = 'none'
mirror.style.width = `${contentWidth}px`
mirror.style.boxSizing = 'border-box'
mirror.style.whiteSpace = 'pre-wrap'
mirror.style.overflowWrap = style.overflowWrap || 'break-word'
mirror.style.wordBreak = style.wordBreak || 'normal'
mirror.style.font = style.font
mirror.style.letterSpacing = style.letterSpacing
mirror.style.lineHeight = style.lineHeight
document.body.appendChild(mirror)
const nextHeights = (markdownValue.value ?? '').split('\n').map((line) => {
const lineEl = document.createElement('div')
lineEl.textContent = line || ' '
mirror.appendChild(lineEl)
return Math.max(fallbackLineHeight, lineEl.getBoundingClientRect().height)
})
document.body.removeChild(mirror)
gutterLineHeights.value = nextHeights.length ? nextHeights : [fallbackLineHeight]
})
}
/**
* 거터 줄 높이를 반환한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {number} 줄 높이(px)
*/
const getGutterLineHeight = (lineIndex) => gutterLineHeights.value[lineIndex] || 28
/**
* textarea 높이를 본문 길이에 맞춘다. 내부 스크롤 없이 부모(`editor-scroll`)만 스크롤한다.
* @returns {void}
*/
const syncTextareaHeight = () => {
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) {
return
}
textarea.style.height = '0px'
textarea.style.height = `${Math.max(MIN_TEXTAREA_HEIGHT_PX, textarea.scrollHeight)}px`
syncGutterLineHeights()
})
}
/**
* textarea 포커스 진입
* @returns {void}
*/
const onTextareaFocus = () => {
window.clearTimeout(blockPanelFocusTimer)
isTextareaFocused.value = true
refreshCaretLogicalLine()
}
/**
* textarea 포커스 이탈(블록 패널으로 이동 시 유지)
* @returns {void}
*/
const onTextareaBlur = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
isTextareaFocused.value = false
syncBlockPanelState()
}, 0)
}
/**
* 블록 패널 포커스 진입(AdminPostForm 오버레이에서 호출)
* @returns {void}
*/
const handleBlockPanelFocusIn = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = true
syncBlockPanelState()
}
/**
* 블록 패널 포커스 이탈
* @returns {void}
*/
const handleBlockPanelFocusOut = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
isBlockPanelEngaged.value = false
if (!isTextareaFocused.value) {
syncBlockPanelState()
}
}, 50)
}
/**
* IME 조합 입력 시작 시 패널 컨텍스트를 유지한다.
* @returns {void}
*/
const handleTextareaCompositionStart = () => {
isTextComposing.value = true
if (activeBlockContext.value) {
lastStableBlockContext.value = activeBlockContext.value
}
}
/**
* IME 조합 입력 종료 후 실제 커서 줄 기준으로 패널을 다시 동기화한다.
* @returns {void}
*/
const handleTextareaCompositionEnd = () => {
isTextComposing.value = false
refreshCaretLogicalLine()
nextTick(syncBlockPanelState)
}
/**
* 커서 위치 기준으로 활성 논리 줄 인덱스를 갱신하고 거터 스크롤을 맞춘다.
* @returns {void}
*/
const refreshCaretLogicalLine = () => {
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) {
return
}
const value = markdownValue.value ?? ''
const pos = Math.min(textarea.selectionStart, value.length)
const lineIndex = value.slice(0, pos).split('\n').length - 1
lastSelectionState.value = {
start: Math.min(textarea.selectionStart, value.length),
end: Math.min(textarea.selectionEnd, value.length)
}
activeLogicalLineIndex.value = Math.max(0, lineIndex)
syncTextareaHeight()
syncBlockPanelState()
if (activeMode.value === 'write' && document.activeElement === textarea) {
syncWriteSlashState()
}
})
}
/**
* 소스(작성) 모드 textarea 현재 줄의 슬래시 입력 상태를 갱신한다.
* @returns {void}
*/
const syncWriteSlashState = () => {
if (activeMode.value !== 'write') {
return
}
const line = activeLogicalLineIndex.value
if (liveSlashSuppressedLines.value.has(line)) {
if (liveSlashSourceLine.value !== null) {
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
return
}
const lines = (markdownValue.value ?? '').split('\n')
if (isCodeFenceBodyLine(lines, line)) {
onLiveSlashEnd({ sourceLine: line })
return
}
const parsed = parseSlashInput(lines[line] ?? '')
if (!parsed) {
onLiveSlashEnd({ sourceLine: line })
return
}
liveSlashSourceLine.value = line
liveSlashQuery.value = parsed.query
nextTick(updateLiveSlashMenuPosition)
}
/**
* textarea의 선택 영역과 스크롤 위치를 기억한다.
* @returns {void}
*/
const rememberTextareaSelection = () => {
const textarea = textareaRef.value
const value = markdownValue.value ?? ''
if (!textarea) {
lastSelectionState.value = {
start: value.length,
end: value.length
}
return
}
lastSelectionState.value = {
start: Math.min(textarea.selectionStart, value.length),
end: Math.min(textarea.selectionEnd, value.length)
}
}
/**
* 가장 가까운 스크롤 컨테이너를 찾는다.
* @param {HTMLElement|null} element - 기준 요소
* @returns {HTMLElement|null} 스크롤 컨테이너
*/
const getNearestScrollContainer = (element) => {
let current = element?.parentElement || null
while (current && current !== document.body && current !== document.documentElement) {
const style = window.getComputedStyle(current)
if (/(auto|scroll)/.test(`${style.overflowY} ${style.overflow}`) && current.scrollHeight > current.clientHeight) {
return current
}
current = current.parentElement
}
return null
}
/**
* textarea의 지정 줄이 화면 안에 보이도록 스크롤한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {void}
*/
const scrollTextareaLineIntoView = (lineIndex) => {
const textarea = textareaRef.value
if (!textarea || !import.meta.client) {
return
}
const style = window.getComputedStyle(textarea)
const paddingTop = parseCssPixelValue(style.paddingTop, 0)
const lineTop = paddingTop + gutterLineHeights.value
.slice(0, Math.max(0, lineIndex))
.reduce((sum, height) => sum + height, 0)
const lineHeight = getGutterLineHeight(lineIndex)
const lineBottom = lineTop + lineHeight
const padding = 140
const scrollContainer = getNearestScrollContainer(editorRootRef.value)
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect()
const textareaRect = textarea.getBoundingClientRect()
const absoluteLineTop = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineTop
const absoluteLineBottom = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineBottom
const viewTop = scrollContainer.scrollTop
const viewBottom = viewTop + scrollContainer.clientHeight
if (absoluteLineTop < viewTop + padding) {
scrollContainer.scrollTop = Math.max(0, absoluteLineTop - padding)
} else if (absoluteLineBottom > viewBottom - padding) {
scrollContainer.scrollTop = Math.max(0, absoluteLineBottom - scrollContainer.clientHeight + padding)
}
return
}
const textareaRect = textarea.getBoundingClientRect()
const absoluteLineTop = textareaRect.top + window.scrollY + lineTop
const absoluteLineBottom = textareaRect.top + window.scrollY + lineBottom
const viewTop = window.scrollY
const viewBottom = viewTop + window.innerHeight
if (absoluteLineTop < viewTop + padding) {
window.scrollTo({ top: Math.max(0, absoluteLineTop - padding) })
} else if (absoluteLineBottom > viewBottom - padding) {
window.scrollTo({ top: Math.max(0, absoluteLineBottom - window.innerHeight + padding) })
}
}
/**
* 소스 textarea의 지정 줄로 포커스와 커서를 이동한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {void}
*/
const focusTextareaAtLine = (lineIndex) => {
nextTick(() => {
const textarea = textareaRef.value
const value = markdownValue.value ?? ''
if (!textarea) {
return
}
const offset = Math.min(getLineStartOffset(lineIndex), value.length)
textarea.focus()
textarea.setSelectionRange(offset, offset)
syncTextareaHeight()
nextTick(() => scrollTextareaLineIntoView(lineIndex))
refreshCaretLogicalLine()
})
}
/**
* 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다.
* @returns {void}
*/
const restoreTextareaFocus = () => {
nextTick(() => {
const textarea = textareaRef.value
const value = markdownValue.value ?? ''
if (!textarea) {
return
}
const start = Math.min(lastSelectionState.value.start, value.length)
const end = Math.min(lastSelectionState.value.end, value.length)
const lineIndex = value.slice(0, start).split('\n').length - 1
textarea.focus()
textarea.setSelectionRange(start, end)
syncTextareaHeight()
nextTick(() => scrollTextareaLineIntoView(lineIndex))
refreshCaretLogicalLine()
})
}
watch(() => props.modelValue, () => {
if (isBlockPanelEngaged.value) {
syncBlockPanelState()
return
}
refreshCaretLogicalLine()
})
watch(markdownValue, (nextValue, previousValue) => {
if (nextValue === previousValue) {
return
}
recordEditorHistory(previousValue)
})
watch(activeMode, (mode) => {
if (mode === 'write') {
nextTick(() => {
syncTextareaHeight()
const focusLine = pendingWriteFocusLine.value
pendingWriteFocusLine.value = null
if (typeof focusLine === 'number') {
focusTextareaAtLine(focusLine)
} else {
restoreTextareaFocus()
}
})
return
}
nextTick(() => {
const previewFocus = pendingPreviewFocus.value
pendingPreviewFocus.value = null
if (!previewFocus) {
scrollPreviewToTop()
return
}
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset, 'center')
})
})
})
/**
* 라이브 미리보기 스크롤을 맨 위로 맞춘다.
* @returns {void}
*/
const scrollPreviewToTop = () => {
if (previewRef.value) {
previewRef.value.scrollTop = 0
}
}
/**
* 라이브 미리보기에서 현재 포커스 또는 화면 상단에 가까운 원본 줄을 찾는다.
* @returns {number|null} 원본 줄 번호
*/
const getCurrentPreviewSourceLine = () => {
if (!import.meta.client || !previewRef.value) {
return null
}
const activeElement = document.activeElement
const focusedLineElement = previewRef.value.contains(activeElement)
? activeElement?.closest?.('[data-source-line]')
: null
const focusedLine = Number(focusedLineElement?.getAttribute?.('data-source-line'))
if (Number.isFinite(focusedLine)) {
return focusedLine
}
const previewRect = previewRef.value.getBoundingClientRect()
const anchorTop = Math.max(previewRect.top, 0) + 24
const candidates = [...previewRef.value.querySelectorAll('[data-source-line]')]
.map((element) => {
const rect = element.getBoundingClientRect()
const line = Number(element.getAttribute('data-source-line'))
return { rect, line }
})
.filter(({ rect, line }) => Number.isFinite(line) && rect.height > 0 && rect.bottom >= anchorTop)
.sort((a, b) => Math.abs(a.rect.top - anchorTop) - Math.abs(b.rect.top - anchorTop))
return candidates[0]?.line ?? null
}
/**
* 라이브 모드의 현재 위치를 소스 모드 복원 대상으로 저장한다.
* @returns {void}
*/
const rememberPreviewPosition = () => {
pendingWriteFocusLine.value = getCurrentPreviewSourceLine() ?? activeLogicalLineIndex.value
}
/**
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
* @returns {number} 줄 번호(0-based)
*/
const getInitialContentFocusLineIndex = () => {
const lines = (markdownValue.value ?? '').split('\n')
if (!lines.length) {
return 0
}
const firstLine = lines[0]?.trim() ?? ''
if (/^#{1,6}\s+/.test(firstLine)) {
return 1
}
return 0
}
/**
* 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
* @param {number} lineIndex - 포커스할 줄
* @returns {number} 보정된 줄 번호
*/
const ensureContentFocusLineExists = (lineIndex) => {
const lines = (markdownValue.value ?? '').split('\n')
if (lineIndex < lines.length) {
return lineIndex
}
markdownValue.value = lines.length ? `${markdownValue.value}\n` : ''
return lines.length
}
/**
* 작성 모드와 미리보기 모드를 전환한다.
* @returns {void}
*/
const toggleEditorMode = () => {
if (activeMode.value === 'write') {
rememberTextareaSelection()
rememberWritePositionForPreview()
} else {
rememberPreviewPosition()
}
activeMode.value = activeMode.value === 'write' ? 'preview' : 'write'
}
/**
* 작성/미리보기 모드를 지정한다.
* @param {'write'|'preview'} mode - 전환할 모드
* @returns {void}
*/
const setEditorMode = (mode) => {
if (activeMode.value === mode) {
return
}
if (activeMode.value === 'write') {
rememberTextareaSelection()
rememberWritePositionForPreview()
} else {
rememberPreviewPosition()
}
activeMode.value = mode
}
onMounted(() => {
/**
* document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다.
* @returns {void}
*/
const onSelectionChange = () => {
syncInlineColorSelectionContext()
syncBlockPanelState()
if (activeMode.value !== 'write') {
return
}
const textarea = textareaRef.value
if (!textarea || document.activeElement !== textarea) {
return
}
refreshCaretLogicalLine()
}
/**
* 에디터 안에서 Cmd/Ctrl+E를 누르면 작성/미리보기 모드를 전환한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const onDocumentKeydown = (event) => {
if (event.key === 'Escape' && isMediaPickerOpen.value) {
event.preventDefault()
closeMediaPicker()
return
}
const root = editorRootRef.value
if (!root || !root.contains(document.activeElement)) {
return
}
const hasCommandModifier = event.metaKey || event.ctrlKey
const isUndoShortcut = hasCommandModifier
&& !event.altKey
&& !event.shiftKey
&& event.key.toLowerCase() === 'z'
const isRedoShortcut = hasCommandModifier
&& !event.altKey
&& (event.shiftKey && event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'y')
if (isUndoShortcut || isRedoShortcut) {
event.preventDefault()
event.stopPropagation()
restoreEditorHistory(isUndoShortcut ? 'undo' : 'redo')
return
}
if (liveSlashVisible.value) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const count = visibleLiveSlashCommands.value.length
if (count) {
liveSlashKeyboardNav.value = true
window.clearTimeout(liveSlashKeyboardNavTimer)
liveSlashKeyboardNavTimer = window.setTimeout(() => {
liveSlashKeyboardNav.value = false
}, 200)
if (event.key === 'ArrowDown') {
liveSlashHighlightedIndex.value = (liveSlashHighlightedIndex.value + 1) % count
} else {
liveSlashHighlightedIndex.value = (liveSlashHighlightedIndex.value - 1 + count) % count
}
}
return
}
if (event.key === 'Enter') {
event.preventDefault()
const command = visibleLiveSlashCommands.value[liveSlashHighlightedIndex.value]
if (command) {
applyLiveSlashCommand(command)
}
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelLiveSlashCommand()
return
}
}
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'e') {
return
}
event.preventDefault()
toggleEditorMode()
}
/**
* 블록 패널 바깥 클릭 시 패널을 닫는다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const onDocumentPointerDown = (event) => {
if (!isBlockPanelVisible.value || isMediaPickerOpen.value) {
return
}
const target = event.target
if (!(target instanceof Element)) {
return
}
if (target.closest('.admin-editor-block-panel')
|| target.closest('.admin-markdown-editor')
|| target.closest('.admin-markdown-editor__media-modal')) {
return
}
closeBlockPanel()
}
document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('keydown', onDocumentKeydown, true)
document.addEventListener('pointerdown', onDocumentPointerDown, true)
window.addEventListener('resize', syncGutterLineHeights)
onBeforeUnmount(() => {
window.clearTimeout(blockPanelFocusTimer)
window.clearTimeout(liveSlashKeyboardNavTimer)
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('keydown', onDocumentKeydown, true)
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
window.removeEventListener('resize', syncGutterLineHeights)
})
refreshCaretLogicalLine()
syncGutterLineHeights()
syncBlockPanelState()
})
/**
* 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
* @returns {void}
*/
const focusFirstBlock = () => {
const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())
if (activeMode.value === 'preview') {
scrollPreviewToTop()
nextTick(() => {
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'start')
})
})
return
}
nextTick(() => {
const textarea = textareaRef.value
const lineStart = getLineStartOffset(focusLine)
if (textarea) {
textarea.focus()
textarea.setSelectionRange(lineStart, lineStart)
textarea.scrollTop = 0
}
activeLogicalLineIndex.value = focusLine
refreshCaretLogicalLine()
})
}
/**
* textarea 선택 영역 정보를 반환한다.
* @returns {{ start: number, end: number, value: string }} 선택 정보
*/
const getSelectionState = () => {
const textarea = textareaRef.value
const value = markdownValue.value
if (!textarea) {
return {
start: value.length,
end: value.length,
value
}
}
return {
start: textarea.selectionStart,
end: textarea.selectionEnd,
value
}
}
/**
* textarea 커서 위치를 갱신한다.
* @param {number} start - 시작 위치
* @param {number} end - 끝 위치
* @param {{ preserveScroll?: boolean }} options - 선택 옵션
* @returns {void}
*/
const setTextareaSelection = (start, end = start, options = {}) => {
const scrollContainer = options.preserveScroll && import.meta.client
? getNearestScrollContainer(editorRootRef.value)
: null
const previousScrollState = options.preserveScroll
? {
textareaTop: textareaRef.value?.scrollTop,
containerTop: scrollContainer?.scrollTop,
windowX: import.meta.client ? window.scrollX : 0,
windowY: import.meta.client ? window.scrollY : 0
}
: null
/**
* textarea와 에디터 스크롤 위치를 복원한다.
* @param {HTMLTextAreaElement} textarea - 대상 textarea
* @returns {void}
*/
const restoreScroll = (textarea) => {
if (!previousScrollState) {
return
}
if (Number.isFinite(previousScrollState.textareaTop)) {
textarea.scrollTop = previousScrollState.textareaTop
}
if (scrollContainer && Number.isFinite(previousScrollState.containerTop)) {
scrollContainer.scrollTop = previousScrollState.containerTop
}
if (import.meta.client && Number.isFinite(previousScrollState.windowY)) {
window.scrollTo(previousScrollState.windowX, previousScrollState.windowY)
}
}
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) {
return
}
textarea.focus()
textarea.setSelectionRange(start, end)
restoreScroll(textarea)
if (import.meta.client) {
window.requestAnimationFrame(() => restoreScroll(textarea))
}
refreshCaretLogicalLine()
})
}
/**
* 선택 영역을 지정 문자열로 교체한다.
* @param {string} replacement - 대체 문자열
* @param {number} cursorOffset - 교체 문자열 안 커서 위치
* @param {number|null} selectionLength - 선택 길이
* @returns {void}
*/
const replaceSelection = (replacement, cursorOffset = replacement.length, selectionLength = null) => {
const { start, end, value } = getSelectionState()
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
const nextStart = start + cursorOffset
setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
}
/**
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
* @param {string} snippet - 삽입할 마크다운
* @returns {void}
*/
const insertBlockSnippet = (snippet) => {
const { start, end, value } = getSelectionState()
const needsBeforeLine = start > 0 && value[start - 1] !== '\n'
const needsAfterLine = end < value.length && value[end] !== '\n'
const replacement = `${needsBeforeLine ? '\n\n' : ''}${snippet}${needsAfterLine ? '\n\n' : ''}`
replaceSelection(replacement)
}
/**
* 선택 텍스트를 인라인 마크다운으로 감싼다.
* @param {string} prefix - 앞 표식
* @param {string} suffix - 뒤 표식
* @param {string} placeholder - 선택이 없을 때 들어갈 텍스트
* @returns {void}
*/
const wrapInline = (prefix, suffix, placeholder) => {
const { start, end, value } = getSelectionState()
const selected = value.slice(start, end)
const inner = selected || placeholder
const replacement = `${prefix}${inner}${suffix}`
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
const selectionStart = start + prefix.length
setTextareaSelection(selectionStart, selectionStart + inner.length)
}
/**
* 선택 텍스트를 링크 마크다운으로 바꾼다.
* @returns {void}
*/
const insertInlineLink = () => {
const { start, end, value } = getSelectionState()
const selected = value.slice(start, end) || '링크 텍스트'
const replacement = `[${selected}](https://)`
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
const urlStart = start + selected.length + 3
setTextareaSelection(urlStart, urlStart + 'https://'.length)
}
/**
* 선택된 줄을 변환한다.
* @param {(line: string) => string} transformLine - 줄 변환 함수
* @returns {void}
*/
const transformSelectedLines = (transformLine) => {
const { start, end, value } = getSelectionState()
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1
const nextLineBreak = value.indexOf('\n', end)
const lineEnd = nextLineBreak === -1 ? value.length : nextLineBreak
const selectedBlock = value.slice(lineStart, lineEnd)
const replacement = selectedBlock.split('\n').map(transformLine).join('\n')
markdownValue.value = `${value.slice(0, lineStart)}${replacement}${value.slice(lineEnd)}`
setTextareaSelection(lineStart, lineStart + replacement.length)
}
/**
* 소스 모드에서 현재 선택 범위가 걸친 줄을 삭제한다.
* @returns {void}
*/
const deleteSelectedSourceLines = () => {
const { start, end, value } = getSelectionState()
const safeStart = Math.min(start, end)
const safeEnd = Math.max(start, end)
const lineStart = value.lastIndexOf('\n', Math.max(0, safeStart - 1)) + 1
const nextLineBreak = value.indexOf('\n', safeEnd)
const lineEnd = nextLineBreak === -1 ? value.length : nextLineBreak + 1
const previousLineBreak = value.lastIndexOf('\n', Math.max(0, lineStart - 2))
const nextValue = `${value.slice(0, lineStart)}${value.slice(lineEnd)}`
const nextSelection = lineStart > 0
? previousLineBreak + 1
: 0
markdownValue.value = nextValue
setTextareaSelection(Math.min(nextSelection, nextValue.length))
}
/**
* 제목 마크다운을 적용한다.
* @param {number} level - 제목 레벨
* @returns {void}
*/
const applyHeading = (level) => {
transformSelectedLines((line) => {
const text = line.replace(/^#{1,6}\s+/, '').trimStart()
return `${'#'.repeat(level)} ${text || '제목'}`
})
}
/**
* 인용 마크다운을 적용한다.
* @returns {void}
*/
const applyQuote = () => {
transformSelectedLines((line) => line.startsWith('> ') ? line : `> ${line}`)
}
/**
* 목록 마크다운을 적용한다.
* @returns {void}
*/
const applyList = () => {
transformSelectedLines((line) => line.startsWith('- ') ? line : `- ${line}`)
}
/**
* 코드 블록을 삽입한다.
* @returns {void}
*/
const insertCodeBlock = () => {
const { start, end, value } = getSelectionState()
const selected = value.slice(start, end) || 'code'
const snippet = `\`\`\`\n${selected}\n\`\`\``
replaceSelection(snippet, 4, selected.length)
}
/**
* 이미지 마크다운 문자열을 생성한다.
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
* @returns {string} 이미지 마크다운
*/
const createImageMarkdown = (image) => serializeImageMarkdown(image)
/**
* 마크다운 이미지 줄과 단독 이미지 URL 줄을 같은 이미지 데이터로 해석한다.
* @param {string} line - 원본 마크다운 줄
* @returns {{ url: string, width: string, pixelWidth: number|null, caption: string, useAlt: boolean }|null} 이미지 데이터
*/
const parseEditorImageLine = (line) => {
const image = parseImageMarkdownLine(line)
if (image) {
return image
}
const trimmed = String(line || '').trim()
if (!isImageUrl(trimmed)) {
return null
}
return {
url: trimmed,
width: 'regular',
pixelWidth: null,
caption: '',
useAlt: false
}
}
/**
* 파일 크기를 표시용 문자열로 변환한다.
* @param {number} size - 바이트 크기
* @returns {string} 표시용 크기
*/
const formatMediaFileSize = (size) => {
const value = Number(size || 0)
if (!Number.isFinite(value) || value <= 0) {
return ''
}
if (value < 1024) {
return `${value} B`
}
if (value < 1024 * 1024) {
return `${Math.round(value / 1024)} KB`
}
return `${(value / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`
}
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
*/
const getMediaItemKind = (item) => {
if (item?.kind) {
return item.kind
}
const source = `${item?.extension || ''} ${item?.url || ''}`.toLowerCase()
if (/\.(jpe?g|png|webp|gif)(\?|$)/i.test(source)) {
return 'image'
}
if (/\.(mp4|webm|mov)(\?|$)/i.test(source)) {
return 'video'
}
if (/\.(mp3|wav|ogg|m4a)(\?|$)/i.test(source)) {
return 'audio'
}
return 'file'
}
/**
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 선택 가능 여부
*/
const isMediaItemSelectableForTarget = (item) => {
if (isPostCardThumbnailUrl(item?.url) || isPostBodyThumbnailUrl(item?.url) || isPageCoverThumbnailUrl(item?.url)) {
return false
}
const kind = getMediaItemKind(item)
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return kind === 'image'
}
return kind === mediaPickerTarget.value
}
/**
* 미디어 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다.
* 선택·삽입 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다.
* @param {Object} item - 미디어 항목
* @returns {string} 미리보기 URL
*/
const getMediaPickerPreviewUrl = (item) => {
const url = String(item?.url || '')
if (getMediaItemKind(item) !== 'image') {
return url
}
if (item?.bodyThumbnailStatus?.hasThumbnail) {
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`)
}
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
}
return url
}
/**
* 미디어 선택 모달 파생 이미지 로드 실패 시 원본 URL로 되돌린다.
* @param {Event} event - 이미지 오류 이벤트
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const onMediaPickerImageError = (event, item) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || image.dataset.fallbackApplied === 'true') {
return
}
image.dataset.fallbackApplied = 'true'
image.src = String(item?.url || '')
}
/**
* 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다.
* @param {File} file - 브라우저 파일
* @returns {boolean} 허용 여부
*/
const isUploadFileAllowedForPicker = (file) => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return file.type.startsWith('image/')
}
if (mediaPickerTarget.value === 'video') {
return file.type.startsWith('video/')
}
if (mediaPickerTarget.value === 'audio') {
return file.type.startsWith('audio/')
}
return !file.type.startsWith('image/') && !file.type.startsWith('video/') && !file.type.startsWith('audio/')
}
/**
* 미디어 항목으로 Prose 미디어 블록을 만든다.
* @param {'video'|'audio'|'file'} blockType - 블록 유형
* @param {Object} item - 미디어 항목
* @returns {string[]} 마크다운 줄
*/
const createMediaBlockMarkdown = (blockType, item) => {
const title = item.title || item.name || ''
if (blockType === 'video') {
return [':::video', `url=${item.url}`, `title=${title}`, 'caption=', ':::']
}
if (blockType === 'audio') {
return [':::audio', `url=${item.url}`, `title=${title}`, 'description=', ':::']
}
return [
':::file',
`url=${item.url}`,
`title=${item.name || title}`,
'description=',
`name=${item.name || title}`,
`size=${formatMediaFileSize(item.size)}`,
':::'
]
}
/**
* 지정 줄 범위를 새 줄 목록으로 교체한다.
* @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)
}
}
/**
* 현재 문서 기준 fenced 블록의 닫는 줄을 찾는다.
* @param {number} startLine - 시작 줄
* @param {number} fallbackEndLine - 기본 끝 줄
* @returns {number} 보정된 끝 줄
*/
const resolveCurrentFencedBlockEndLine = (startLine, fallbackEndLine) => {
const lines = (markdownValue.value || '').split('\n')
const opener = lines[startLine]?.trim() ?? ''
if (opener.startsWith(':::')) {
const closingLine = lines.findIndex((line, index) => index > startLine && line.trim() === ':::')
return closingLine >= 0 ? closingLine : fallbackEndLine
}
if (opener.startsWith('```')) {
const closingLine = lines.findIndex((line, index) => index > startLine && line.trim().startsWith('```'))
return closingLine >= 0 ? closingLine : fallbackEndLine
}
return fallbackEndLine
}
/**
* 현재 미디어 블록을 이미지 목록 기준으로 다시 작성한다.
* @param {Array<{ alt: string, url: string, width?: string }>} images - 이미지 목록
* @returns {void}
*/
const replaceActiveMediaImages = (images) => {
const block = activeMediaBlock.value
if (!block) {
return
}
if (block.kind === '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, pixelWidth: number|null }>} patch - 변경 값
* @returns {void}
*/
const updateActiveMediaImage = (imageIndex, patch) => {
const block = activeMediaBlock.value
if (!block) {
return
}
ensureBlockPanelEngaged()
const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image)
replaceActiveMediaImages(images)
}
/**
* 현재 미디어 이미지의 파일명 캡션 사용 여부를 바꾼다.
* @param {number} imageIndex - 이미지 인덱스
* @param {boolean} enabled - 파일명 캡션 사용 여부
* @returns {void}
*/
const setActiveMediaUseAlt = (imageIndex, enabled) => {
const image = activeMediaBlock.value?.images[imageIndex]
if (!image) {
return
}
const filename = getImageDefaultAltLabel(image.url)
const patch = { useAlt: enabled }
if (enabled) {
if (!String(image.caption || '').trim()) {
const legacy = String(image.legacyBracketLabel || '').trim()
patch.caption = legacy && legacy !== filename ? legacy : filename
}
} else if (String(image.caption || '').trim() === filename) {
patch.caption = ''
}
updateActiveMediaImage(imageIndex, patch)
}
/**
* 미리보기에서 갤러리 순서 변경
* @param {{ startLine: number, endLine: number, images: Array<Object> }} payload - 갤러리 줄 범위·이미지 목록
* @returns {void}
*/
const onPreviewGalleryReorder = ({ startLine, endLine, images }) => {
if (typeof startLine !== 'number' || typeof endLine !== 'number' || !Array.isArray(images)) {
return
}
replaceLineRange(startLine, endLine, [
':::gallery',
...images.map(createImageMarkdown),
':::'
], false)
}
/**
* 라이브 모드에서 두 이미지 블록을 갤러리 fenced block으로 병합한다.
* @param {{ sourceLine: number, targetLine: number }} payload - 원본·대상 줄(0-based)
* @returns {void}
*/
const onPreviewMergeImagesToGallery = ({ sourceLine, targetLine }) => {
if (typeof sourceLine !== 'number' || typeof targetLine !== 'number' || sourceLine === targetLine) {
return
}
const lines = (markdownValue.value || '').split('\n')
const firstLine = Math.min(sourceLine, targetLine)
const secondLine = Math.max(sourceLine, targetLine)
const firstImage = parseEditorImageLine(lines[firstLine] || '')
const secondImage = parseEditorImageLine(lines[secondLine] || '')
if (!firstImage || !secondImage) {
return
}
const orderedImages = sourceLine < targetLine
? [firstImage, secondImage]
: [secondImage, firstImage]
const nextLines = [
...lines.slice(0, firstLine),
':::gallery',
...orderedImages.map(createImageMarkdown),
':::',
...lines.slice(secondLine + 1)
]
markdownValue.value = nextLines.join('\n')
}
/**
* 라이브 모드에서 단일 이미지 블록을 기존 갤러리에 추가한다.
* @param {{ sourceLine: number, startLine: number, endLine: number, targetIndex: number, image?: Object }} payload - 이미지 줄·갤러리 범위·삽입 위치
* @returns {void}
*/
const onPreviewInsertImageToGallery = ({
sourceLine,
startLine,
endLine,
targetIndex,
image
}) => {
if (typeof sourceLine !== 'number'
|| typeof startLine !== 'number'
|| typeof endLine !== 'number'
|| typeof targetIndex !== 'number'
|| sourceLine >= startLine && sourceLine <= endLine) {
return
}
const lines = (markdownValue.value || '').split('\n')
const sourceImage = image && image.url ? image : parseEditorImageLine(lines[sourceLine] || '')
const galleryImages = lines
.slice(startLine + 1, endLine)
.map((line) => parseEditorImageLine(line))
.filter(Boolean)
if (!sourceImage || !galleryImages.length) {
return
}
const nextGalleryImages = [...galleryImages]
const insertIndex = Math.max(0, Math.min(targetIndex, nextGalleryImages.length))
nextGalleryImages.splice(insertIndex, 0, sourceImage)
const withoutSource = [
...lines.slice(0, sourceLine),
...lines.slice(sourceLine + 1)
]
const adjustedStartLine = startLine - (sourceLine < startLine ? 1 : 0)
const adjustedEndLine = endLine - (sourceLine < endLine ? 1 : 0)
markdownValue.value = [
...withoutSource.slice(0, adjustedStartLine),
':::gallery',
...nextGalleryImages.map(createImageMarkdown),
':::',
...withoutSource.slice(adjustedEndLine + 1)
].join('\n')
}
/**
* 라이브 모드 갤러리에서 특정 이미지를 삭제한다.
* @param {{ startLine: number, endLine: number, imageIndex: number }} payload - 갤러리 범위·이미지 인덱스
* @returns {void}
*/
const onPreviewRemoveGalleryImage = ({ startLine, endLine, imageIndex }) => {
if (typeof startLine !== 'number' || typeof endLine !== 'number' || typeof imageIndex !== 'number') {
return
}
const lines = (markdownValue.value || '').split('\n')
const galleryImages = lines
.slice(startLine + 1, endLine)
.map((line) => parseEditorImageLine(line))
.filter(Boolean)
if (imageIndex < 0 || imageIndex >= galleryImages.length) {
return
}
const remaining = galleryImages.filter((_, index) => index !== imageIndex)
const replacementLines = remaining.length === 0
? []
: remaining.length === 1
? [createImageMarkdown(remaining[0])]
: [
':::gallery',
...remaining.map(createImageMarkdown),
':::'
]
replaceLineRange(startLine, endLine, replacementLines, false)
activeLogicalLineIndex.value = Math.max(0, Math.min(startLine + imageIndex, startLine + replacementLines.length - 1))
}
/**
* 라이브 모드에서 갤러리 이미지를 블록 사이에 단일 이미지 줄로 분리한다.
* @param {{ startLine: number, endLine: number, imageIndex: number, insertBeforeLine: number, image?: Object }} payload - 갤러리 범위·삽입 위치
* @returns {void}
*/
const onPreviewExtractGalleryImage = ({
startLine,
endLine,
imageIndex,
insertBeforeLine,
image
}) => {
if (typeof startLine !== 'number'
|| typeof endLine !== 'number'
|| typeof imageIndex !== 'number'
|| typeof insertBeforeLine !== 'number') {
return
}
const lines = (markdownValue.value || '').split('\n')
const galleryImages = lines
.slice(startLine + 1, endLine)
.map((line) => parseEditorImageLine(line))
.filter(Boolean)
if (imageIndex < 0 || imageIndex >= galleryImages.length) {
return
}
const extracted = image && image.url ? image : galleryImages[imageIndex]
const remaining = galleryImages.filter((_, index) => index !== imageIndex)
const galleryReplacement = remaining.length === 0
? []
: remaining.length === 1
? [createImageMarkdown(remaining[0])]
: [
':::gallery',
...remaining.map(createImageMarkdown),
':::'
]
const withoutGallery = [
...lines.slice(0, startLine),
...lines.slice(endLine + 1)
]
const removedSpan = endLine - startLine + 1
const addedSpan = galleryReplacement.length
let insertAt = insertBeforeLine
if (insertBeforeLine > endLine) {
insertAt -= removedSpan - addedSpan
} else if (insertBeforeLine > startLine) {
insertAt = startLine + addedSpan
}
insertAt = Math.max(0, Math.min(insertAt, withoutGallery.length))
withoutGallery.splice(insertAt, 0, createImageMarkdown(extracted))
let galleryAt = startLine
if (startLine > insertAt) {
galleryAt += 1
}
withoutGallery.splice(galleryAt, 0, ...galleryReplacement)
markdownValue.value = withoutGallery.join('\n')
}
/**
* 미리보기 인라인 편집 결과를 마크다운 본문에 반영한다.
* @param {{ startLine: number, endLine: number, replacementLines: string[] }} payload - 줄 범위·대체 줄
* @returns {void}
*/
const onPreviewBlockContentChange = ({ startLine, endLine, replacementLines }) => {
if (typeof startLine !== 'number' || typeof endLine !== 'number' || !Array.isArray(replacementLines)) {
return
}
replaceLineRange(startLine, resolveCurrentFencedBlockEndLine(startLine, endLine), replacementLines, false)
}
/**
* 라이브 모드 교차 선택 삭제 결과를 본문에 반영한다.
* @param {{ value: string }} payload - 갱신된 마크다운
* @returns {void}
*/
const onPreviewContentReplace = ({ value }) => {
if (typeof value !== 'string') {
return
}
markdownValue.value = value
}
/**
* 라이브 모드 하단에서 새 문단 줄을 추가한다.
* @returns {void}
*/
const onPreviewAppendParagraph = () => {
const value = markdownValue.value ?? ''
const trimmed = value.replace(/\n+$/, '')
markdownValue.value = trimmed ? `${trimmed}\n\n` : ''
}
/**
* 라이브 모드에서 지정 줄 아래에 마크다운 줄을 삽입한다.
* @param {{ afterLine: number, lines: string[], focusLine?: number }} payload - 삽입 위치·줄
* @returns {void}
*/
const onPreviewInsertAfterLine = ({ afterLine, lines, focusLine }) => {
if (typeof afterLine !== 'number' || !Array.isArray(lines)) {
return
}
const sourceLines = (markdownValue.value ?? '').split('\n')
const nextFocusLine = typeof focusLine === 'number'
? focusLine
: afterLine + 1
markdownValue.value = [
...sourceLines.slice(0, afterLine + 1),
...lines,
...sourceLines.slice(afterLine + 1)
].join('\n')
activeLogicalLineIndex.value = nextFocusLine
isBlockPanelEngaged.value = false
lastStableBlockContext.value = null
syncBlockPanelState()
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(nextFocusLine, 0, 'start')
})
}
/**
* 라이브 모드에서 현재 줄을 삭제한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {void}
*/
const onPreviewDeleteLine = (lineIndex) => {
if (typeof lineIndex !== 'number' || lineIndex < 0) {
return
}
const sourceLines = (markdownValue.value ?? '').split('\n')
if (lineIndex >= sourceLines.length) {
return
}
const nextLines = [
...sourceLines.slice(0, lineIndex),
...sourceLines.slice(lineIndex + 1)
]
markdownValue.value = nextLines.length ? nextLines.join('\n') : ''
}
/**
* 라이브 모드 미리보기 루트 단축키를 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handlePreviewKeydownCapture = (event) => {
const target = event.target
const isSelectAllShortcut = (event.metaKey || event.ctrlKey)
&& !event.shiftKey
&& !event.altKey
&& event.key.toLowerCase() === 'a'
if (isSelectAllShortcut) {
if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) {
return
}
event.preventDefault()
event.stopPropagation()
previewRendererRef.value?.selectAllLiveDocument()
return
}
const isDeleteShortcut = (event.metaKey || event.ctrlKey)
&& event.shiftKey
&& event.key.toLowerCase() === 'k'
if (!isDeleteShortcut) {
return
}
if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) {
return
}
const lineIndex = getCurrentPreviewSourceLine()
if (typeof lineIndex !== 'number' || lineIndex < 0) {
return
}
event.preventDefault()
event.stopPropagation()
onPreviewDeleteLine(lineIndex)
}
/**
* 라이브 모드에서 현재 줄을 이전 줄 끝에 병합한다.
* @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄
* @returns {void}
*/
/**
* 슬래시 메뉴 좌표를 뷰포트 안으로 맞춘다.
* @param {number} top - 후보 top(px)
* @param {number} left - 후보 left(px)
* @param {number} menuHeight - 메뉴 높이
* @param {number} menuWidth - 메뉴 너비
* @param {number} anchorTop - 기준 요소 top(위로 넘칠 때)
* @returns {{ top: number, left: number }}
*/
const clampSlashMenuPosition = (top, left, menuHeight, menuWidth, anchorTop) => {
const gap = 4
const padding = 8
const viewportBottom = window.innerHeight - padding
const viewportRight = window.innerWidth - padding
let nextTop = top
if (nextTop + menuHeight > viewportBottom) {
const aboveTop = anchorTop - menuHeight - gap
if (aboveTop >= padding) {
nextTop = aboveTop
} else {
nextTop = Math.max(padding, viewportBottom - menuHeight)
}
}
let nextLeft = left
if (nextLeft + menuWidth > viewportRight) {
nextLeft = viewportRight - menuWidth
}
return {
top: nextTop,
left: Math.max(padding, nextLeft)
}
}
/**
* 미리보기 모드 슬래시 메뉴 위치를 갱신한다.
* @returns {void}
*/
const updatePreviewSlashMenuPosition = () => {
const element = previewRef.value?.querySelector(`[data-source-line="${liveSlashSourceLine.value}"]`)
if (!element) {
return
}
const rect = element.getBoundingClientRect()
const menuEl = liveSlashMenuRef.value
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const gap = 4
liveSlashMenuPosition.value = clampSlashMenuPosition(
rect.bottom + gap,
rect.left,
menuHeight,
menuWidth,
rect.top
)
}
/**
* 소스 모드 textarea 슬래시 메뉴 위치를 갱신한다.
* @returns {void}
*/
const updateWriteSlashMenuPosition = () => {
const textarea = textareaRef.value
const line = liveSlashSourceLine.value
if (!textarea || line === null) {
return
}
const value = markdownValue.value ?? ''
const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0)
const coords = getTextareaCaretCoordinates(textarea, lineStart)
const rect = textarea.getBoundingClientRect()
const menuEl = liveSlashMenuRef.value
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const gap = 4
const anchorTop = rect.top + coords.top - textarea.scrollTop
liveSlashMenuPosition.value = clampSlashMenuPosition(
anchorTop + coords.height + gap,
rect.left + coords.left - textarea.scrollLeft,
menuHeight,
menuWidth,
anchorTop
)
}
/**
* 슬래시 메뉴 위치를 갱신한다(뷰포트 아래로 넘치면 위쪽·좌우 클램프).
* @returns {void}
*/
const updateLiveSlashMenuPosition = () => {
if (!import.meta.client || liveSlashSourceLine.value === null) {
return
}
nextTick(() => {
nextTick(() => {
if (activeMode.value === 'write') {
updateWriteSlashMenuPosition()
return
}
updatePreviewSlashMenuPosition()
})
})
}
/**
* 키보드로 선택한 슬래시 메뉴 항목이 보이도록 스크롤한다.
* @returns {void}
*/
const scrollLiveSlashHighlightedIntoView = () => {
if (!import.meta.client || !liveSlashVisible.value) {
return
}
nextTick(() => {
const menu = liveSlashMenuRef.value
if (!menu) {
return
}
const highlightedItem = menu.querySelector('.admin-markdown-editor__slash-item--active')
if (!highlightedItem) {
return
}
const itemTop = highlightedItem.offsetTop
const itemBottom = itemTop + highlightedItem.offsetHeight
const viewTop = menu.scrollTop
const viewBottom = viewTop + menu.clientHeight
if (itemTop < viewTop) {
menu.scrollTop = itemTop
} else if (itemBottom > viewBottom) {
menu.scrollTop = itemBottom - menu.clientHeight
}
})
}
/**
* 슬래시 메뉴 항목 mouseenter 시 하이라이트(키보드 탐색 중에는 무시).
* @param {number} commandIndex - 명령 인덱스
* @returns {void}
*/
const onSlashMenuItemMouseEnter = (commandIndex) => {
if (liveSlashKeyboardNav.value) {
return
}
liveSlashHighlightedIndex.value = commandIndex
}
/**
* 슬래시 명령 입력을 갱신한다.
* @param {{ sourceLine: number, query: string }} payload - 줄·검색어
* @returns {void}
*/
const onLiveSlashUpdate = (payload) => {
if (typeof payload?.sourceLine !== 'number') {
return
}
if (liveSlashSuppressedLines.value.has(payload.sourceLine)) {
if (liveSlashSourceLine.value !== null) {
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
return
}
const lines = (markdownValue.value ?? '').split('\n')
if (isCodeFenceBodyLine(lines, payload.sourceLine)) {
onLiveSlashEnd({ sourceLine: payload.sourceLine })
return
}
liveSlashSourceLine.value = payload.sourceLine
liveSlashQuery.value = String(payload.query ?? '')
nextTick(updateLiveSlashMenuPosition)
}
watch(liveSlashQuery, () => {
liveSlashHighlightedIndex.value = 0
})
watch(visibleLiveSlashCommands, (commands) => {
if (liveSlashHighlightedIndex.value >= commands.length) {
liveSlashHighlightedIndex.value = Math.max(0, commands.length - 1)
}
updateLiveSlashMenuPosition()
})
watch(liveSlashHighlightedIndex, () => {
scrollLiveSlashHighlightedIntoView()
})
watch(liveSlashVisible, (visible) => {
if (!import.meta.client) {
return
}
const onReposition = () => updateLiveSlashMenuPosition()
if (visible) {
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
previewRef.value?.addEventListener('scroll', onReposition)
textareaRef.value?.addEventListener('scroll', onReposition)
updateLiveSlashMenuPosition()
scrollLiveSlashHighlightedIntoView()
return
}
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
previewRef.value?.removeEventListener('scroll', onReposition)
textareaRef.value?.removeEventListener('scroll', onReposition)
})
/**
* 슬래시 명령 입력을 종료한다.
* @returns {void}
*/
const onLiveSlashEnd = (payload) => {
if (typeof payload?.sourceLine === 'number') {
const next = new Set(liveSlashSuppressedLines.value)
next.delete(payload.sourceLine)
liveSlashSuppressedLines.value = next
}
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
/**
* 슬래시 명령 입력을 취소한다(줄 내용은 유지, 메뉴만 닫음).
* @returns {void}
*/
const cancelLiveSlashCommand = () => {
const line = liveSlashSourceLine.value
if (line !== null) {
liveSlashSuppressedLines.value = new Set([...liveSlashSuppressedLines.value, line])
}
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
/**
* 슬래시 명령 적용 후 미리보기 인라인 편집에 포커스를 복원한다.
* @param {number} line - 줄 번호(0-based)
* @param {string[]} replacementLines - 삽입된 줄
* @returns {void}
*/
const focusPreviewAfterSlashCommand = (line, replacementLines) => {
if (!import.meta.client) {
return
}
const { line: focusLine, offset } = getSlashCommandFocusTarget(line, replacementLines)
nextTick(() => {
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset)
})
})
})
})
}
/**
* 소스 모드 textarea에 슬래시 명령 적용 후 커서를 복원한다.
* @param {number} line - 줄 번호(0-based)
* @param {string[]} replacementLines - 삽입된 줄
* @returns {void}
*/
const focusTextareaAfterSlashCommand = (line, replacementLines) => {
const value = markdownValue.value ?? ''
const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0)
const { offset } = getSlashCommandFocusTarget(line, replacementLines)
setTextareaSelection(lineStart + offset, lineStart + offset)
}
/**
* 슬래시 명령을 적용한다.
* @param {import('../../lib/markdown-slash-commands.js').MarkdownSlashCommand} command - 명령
* @returns {void}
*/
const applyLiveSlashCommand = (command) => {
if (!command || liveSlashSourceLine.value === null) {
return
}
const line = liveSlashSourceLine.value
if (['media-image', 'media-gallery', 'media-video', 'media-audio', 'media-file'].includes(command.action)) {
liveSlashInsertLine.value = line
replaceLineRange(line, line, [], false)
onLiveSlashEnd()
openMediaPicker(command.action.replace('media-', ''))
return
}
if (command.action === 'lines' && Array.isArray(command.lines)) {
const replacementLines = command.lines
const nextSuppressed = new Set(liveSlashSuppressedLines.value)
nextSuppressed.delete(line)
liveSlashSuppressedLines.value = nextSuppressed
replaceLineRange(line, line, replacementLines, false)
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
if (activeMode.value === 'write') {
focusTextareaAfterSlashCommand(line, replacementLines)
} else {
focusPreviewAfterSlashCommand(line, replacementLines)
}
}
}
/**
* 슬래시 명령 Enter 적용
* @param {{ sourceLine: number, value: string }} payload - 줄·입력 값
* @returns {void}
*/
const onLiveSlashApply = (payload) => {
const parsed = parseSlashInput(payload?.value)
const command = resolveSlashCommand(parsed?.query ?? liveSlashQuery.value)
if (!command) {
return
}
applyLiveSlashCommand(command)
}
/**
* 지정 줄에 마크다운 줄을 삽입한다.
* @param {number} lineIndex - 삽입 위치(0-based)
* @param {string[]} insertLines - 삽입 줄
* @returns {void}
*/
const insertMarkdownAtLine = (lineIndex, insertLines) => {
const sourceLines = (markdownValue.value ?? '').split('\n')
markdownValue.value = [
...sourceLines.slice(0, lineIndex),
...insertLines,
...sourceLines.slice(lineIndex)
].join('\n')
}
const onPreviewMergeWithPreviousLine = ({ lineIndex, mergedLine }) => {
if (typeof lineIndex !== 'number' || lineIndex <= 0) {
return
}
const sourceLines = (markdownValue.value ?? '').split('\n')
if (lineIndex >= sourceLines.length) {
return
}
markdownValue.value = [
...sourceLines.slice(0, lineIndex - 1),
String(mergedLine ?? ''),
...sourceLines.slice(lineIndex + 1)
].join('\n')
}
/**
* 현재 갤러리 이미지 순서를 바꾼다.
* @param {number} imageIndex - 이동할 이미지 인덱스
* @param {-1|1} direction - 이동 방향
* @returns {void}
*/
const moveActiveGalleryImage = (imageIndex, direction) => {
const block = activeMediaBlock.value
if (!block || block.kind !== '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.kind !== 'gallery') {
insertGallery(images)
return
}
replaceActiveMediaImages([...block.images, ...images])
}
/**
* 현재 링크·iframe·HTML 블록 값을 수정한다.
* @param {string} url - 블록 값
* @returns {void}
*/
const updateActiveEmbedUrl = (url) => {
const block = activeBlockContext.value
if (!block || !['link', 'iframe', 'html'].includes(block.kind)) {
return
}
ensureBlockPanelEngaged()
const trimmed = String(url || '').trim()
if (!trimmed) {
replaceLineRange(block.startLine, block.endLine, [], false)
return
}
replaceLineRange(block.startLine, block.endLine, [`:::${block.kind}`, trimmed, ':::'], false)
}
/**
* 현재 인용 블록 배경색을 수정한다.
* @param {string} background - 배경색 옵션
* @returns {void}
*/
const updateActiveQuoteBackground = (background) => {
const block = activeBlockContext.value
if (!block || block.kind !== 'quote') {
return
}
ensureBlockPanelEngaged()
const value = String(background || '').trim()
if (!QUOTE_BACKGROUND_OPTIONS.includes(value)) {
return
}
const optionLine = `> [!bg=${value}]`
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
if (block.hasQuoteOptions) {
nextLines.splice(block.startLine, 1, optionLine)
} else {
nextLines.splice(block.startLine, 0, optionLine)
}
markdownValue.value = nextLines.join('\n')
}
/**
* 현재 콜아웃 블록 옵션을 수정한다.
* @param {Partial<{ calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string, title: string }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveCalloutOptions = (patch = {}) => {
const block = activeBlockContext.value
if (!block || block.kind !== 'callout') {
return
}
ensureBlockPanelEngaged()
const nextBackground = CALLOUT_BACKGROUND_OPTIONS.includes(patch.calloutBackground)
? patch.calloutBackground
: block.calloutBackground
const nextLine = buildCalloutOpenerLine({
calloutEmojiEnabled: patch.calloutEmojiEnabled ?? block.calloutEmojiEnabled,
calloutEmoji: patch.calloutEmoji ?? block.calloutEmoji,
calloutBackground: nextBackground,
title: patch.title ?? block.title
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
}
/**
* 현재 코드 블록 옵션을 수정한다.
* @param {Partial<{ language: string, showLineNumbers: boolean }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveCodeOptions = (patch = {}) => {
const block = activeBlockContext.value
if (!block || block.kind !== 'code') {
return
}
ensureBlockPanelEngaged()
const nextLine = buildCodeFenceOpener({
language: patch.language ?? block.language,
showLineNumbers: patch.showLineNumbers ?? block.showLineNumbers
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
}
/**
* 현재 토글 블록 옵션을 수정한다.
* @param {Partial<{ defaultOpen: boolean }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveToggleOptions = (patch = {}) => {
const block = activeBlockContext.value
if (!block || block.kind !== 'toggle') {
return
}
ensureBlockPanelEngaged()
const nextLine = buildToggleOpenerLine({
title: block.title,
defaultOpen: patch.defaultOpen ?? block.defaultOpen
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
}
/**
* 현재 선택된 텍스트에 인라인 색상을 적용한다.
* @param {string} color - 색상 이름
* @returns {void}
*/
const updateActiveInlineColor = (color) => {
const context = inlineColorSelectionContext.value
if (!context || context.kind !== 'inline-color') {
return
}
ensureBlockPanelEngaged()
if (context.mode === 'source') {
const value = markdownValue.value || ''
const start = Math.max(0, Math.min(context.start ?? 0, value.length))
const end = Math.max(start, Math.min(context.end ?? start, value.length))
const selectedText = value.slice(start, end)
const replacement = formatInlineColorMarkdown(color, selectedText)
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
inlineColorSelectionContext.value = null
setTextareaSelection(start + replacement.length, start + replacement.length, { preserveScroll: true })
return
}
if (context.mode === 'live' && Number.isInteger(context.sourceLine)) {
const lines = (markdownValue.value || '').split('\n')
const line = lines[context.sourceLine]
if (typeof line !== 'string') {
return
}
const prefixLength = getMarkdownLineBodyPrefixLength(line, context.bodyText || '')
const absoluteStart = prefixLength + Math.max(0, context.startOffset ?? 0)
const absoluteEnd = prefixLength + Math.max(context.startOffset ?? 0, context.endOffset ?? 0)
const selectedText = line.slice(absoluteStart, absoluteEnd)
const replacement = formatInlineColorMarkdown(color, selectedText)
const nextVisibleOffset = (context.startOffset ?? 0) + selectedText.length
lines.splice(context.sourceLine, 1, `${line.slice(0, absoluteStart)}${replacement}${line.slice(absoluteEnd)}`)
markdownValue.value = lines.join('\n')
inlineColorSelectionContext.value = null
pendingPreviewFocus.value = {
line: context.sourceLine,
offset: nextVisibleOffset
}
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(
context.sourceLine,
0,
'auto',
nextVisibleOffset,
'nearest'
)
})
}
}
/**
* 이미지 마크다운을 삽입한다.
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
* @returns {void}
*/
const insertImage = (image) => {
insertBlockSnippet(createImageMarkdown(image))
}
/**
* 갤러리 마크다운을 삽입한다.
* @param {Array<{ url: string, alt?: string, width?: string }>} images - 이미지 목록
* @returns {void}
*/
const insertGallery = (images) => {
if (!images.length) {
return
}
insertBlockSnippet([':::gallery', ...images.map(createImageMarkdown), ':::'].join('\n'))
}
/**
* 미디어 목록을 불러온다.
* @returns {Promise<void>}
*/
const fetchMediaItems = async () => {
isLoadingMedia.value = true
try {
const response = await $fetch('/admin/api/media', {
query: {
paged: '1',
tab: 'library',
page: mediaPickerPage.value,
pageSize: mediaPickerPageSize,
kind: mediaPickerKind.value,
search: mediaSearchQuery.value.trim() || undefined
}
})
mediaItems.value = response?.items || []
mediaPickerPagination.value = response?.pagination || {
page: 1,
pageSize: mediaPickerPageSize,
total: mediaItems.value.length,
pageCount: 1
}
mediaPickerPage.value = mediaPickerPagination.value.page || 1
} finally {
isLoadingMedia.value = false
}
}
/**
* 미디어 선택 창을 연다.
* @param {'image'|'gallery'|'active-gallery'} target - 삽입 대상
* @returns {Promise<void>}
*/
const openMediaPicker = async (target) => {
if (target === 'active-gallery') {
ensureBlockPanelEngaged()
}
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaPickerPagination.value = {
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
}
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
isMediaPickerOpen.value = true
}
defineExpose({
focusFirstBlock,
toggleEditorMode,
activeMode,
handleBlockPanelFocusIn,
handleBlockPanelFocusOut,
updateActiveMediaImage,
setActiveMediaUseAlt,
moveActiveGalleryImage,
removeActiveMediaImage,
appendImagesToActiveGallery,
updateActiveEmbedUrl,
updateActiveQuoteBackground,
updateActiveCalloutOptions,
updateActiveCodeOptions,
updateActiveToggleOptions,
updateActiveInlineColor,
openMediaPicker
})
/**
* 미디어 선택 창을 닫는다.
* @returns {void}
*/
const closeMediaPicker = () => {
if (isUploading.value) {
return
}
isMediaPickerOpen.value = false
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaItems.value = []
mediaPickerPagination.value = {
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
}
liveSlashInsertLine.value = null
}
/**
* 현재 미디어 선택 페이지의 표시 목록
* @returns {Array<Object>} 표시할 미디어 목록
*/
const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForTarget))
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
const mediaPickerRangeLabel = computed(() => {
const total = Number(mediaPickerPagination.value.total) || 0
if (total <= 0) {
return '0개'
}
const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value)
const pageSize = Number(mediaPickerPagination.value.pageSize) || mediaPickerPageSize
const start = ((page - 1) * pageSize) + 1
const end = Math.min(start + pageSize - 1, total)
return `${total}개 중 ${start}-${end}개`
})
const mediaPickerPageItems = computed(() => {
const total = mediaPickerPageCount.value
const current = Math.min(Math.max(Number(mediaPickerPage.value) || 1, 1), total)
const pages = new Set([1, total, current])
for (let page = current - 2; page <= current + 2; page += 1) {
pages.add(page)
}
if (current <= 3) {
pages.add(2)
pages.add(3)
pages.add(4)
}
if (current >= total - 2) {
pages.add(total - 3)
pages.add(total - 2)
pages.add(total - 1)
}
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
return orderedPages.reduce((items, page, index) => {
const previousPage = orderedPages[index - 1]
if (previousPage && page - previousPage > 1) {
items.push(`ellipsis-${previousPage}-${page}`)
}
items.push(page)
return items
}, [])
})
/**
* 갤러리 미디어 선택 순서를 반환한다.
* @param {string} url - 미디어 URL
* @returns {number} 1부터 시작하는 선택 순서, 선택되지 않았으면 0
*/
const getGallerySelectionOrder = (url) => {
if (!isGalleryMediaPicker.value) {
return 0
}
const index = selectedMediaUrls.value.indexOf(url)
return index >= 0 ? index + 1 : 0
}
/**
* 미디어 선택 모달 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {Promise<void>}
*/
const setMediaPickerPage = async (page) => {
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
await fetchMediaItems()
}
watch(mediaSearchQuery, async () => {
if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') {
return
}
mediaPickerPage.value = 1
await fetchMediaItems()
})
watch(activeMediaPickerTab, async (tab) => {
if (!isMediaPickerOpen.value || tab !== 'library') {
return
}
mediaPickerPage.value = 1
await fetchMediaItems()
})
/**
* 미디어 선택 상태를 토글한다.
* @param {Object} mediaItem - 미디어 항목
* @returns {void}
*/
const toggleMediaSelection = (mediaItem) => {
if (!isGalleryMediaPicker.value) {
selectedMediaUrls.value = [mediaItem.url]
selectedMediaItemMap.value = {
[mediaItem.url]: mediaItem
}
return
}
if (selectedMediaUrls.value.includes(mediaItem.url)) {
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => url !== mediaItem.url)
const nextMap = { ...selectedMediaItemMap.value }
delete nextMap[mediaItem.url]
selectedMediaItemMap.value = nextMap
return
}
selectedMediaUrls.value = [...selectedMediaUrls.value, mediaItem.url]
selectedMediaItemMap.value = {
...selectedMediaItemMap.value,
[mediaItem.url]: mediaItem
}
}
/**
* 미디어 항목 목록을 마크다운에 삽입한다.
* @param {Array<Object>} selectedItems - 선택 또는 업로드된 미디어 항목
* @returns {void}
*/
const insertSelectedMediaItems = (selectedItems) => {
if (liveSlashInsertLine.value !== null) {
const line = liveSlashInsertLine.value
if (mediaPickerTarget.value === 'image') {
const [item] = selectedItems
if (item) {
insertMarkdownAtLine(line, [createImageMarkdown({
url: item.url,
useAlt: false,
caption: ''
})])
}
} else if (mediaPickerTarget.value === 'gallery' && selectedItems.length) {
insertMarkdownAtLine(line, [
':::gallery',
...selectedItems.map((item) => createImageMarkdown({
url: item.url,
useAlt: false,
caption: ''
})),
':::'
])
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
const [item] = selectedItems
if (item) {
insertMarkdownAtLine(line, createMediaBlockMarkdown(mediaPickerTarget.value, item))
}
}
liveSlashInsertLine.value = null
return
}
if (mediaPickerTarget.value === 'image') {
const [item] = selectedItems
if (item) {
insertImage({
url: item.url,
useAlt: false,
caption: ''
})
}
} else if (mediaPickerTarget.value === 'active-gallery') {
appendImagesToActiveGallery(selectedItems.map((item) => ({
url: item.url,
useAlt: false,
caption: ''
})))
} else if (mediaPickerTarget.value === 'gallery') {
insertGallery(selectedItems.map((item) => ({
url: item.url,
useAlt: false,
caption: ''
})))
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
const [item] = selectedItems
if (item) {
insertBlockSnippet(createMediaBlockMarkdown(mediaPickerTarget.value, item).join('\n'))
}
}
}
/**
* 선택한 미디어를 마크다운에 삽입한다.
* @returns {void}
*/
const applyMediaSelection = () => {
const selectedItems = selectedMediaUrls.value
.map((url) => selectedMediaItemMap.value[url] || mediaItems.value.find((item) => item.url === url))
.filter(Boolean)
insertSelectedMediaItems(selectedItems)
closeMediaPicker()
}
/**
* 업로드 API 오류 메시지를 사용자용 문장으로 변환한다.
* @param {unknown} error - fetch 오류
* @returns {string} 오류 메시지
*/
const resolveUploadFetchErrorMessage = (error) => {
const responseMessage = error?.data?.message || error?.response?._data?.message
if (typeof responseMessage === 'string' && responseMessage.trim()) {
return responseMessage.trim()
}
if (error?.statusCode === 413 || error?.status === 413) {
return '업로드 가능한 파일 크기를 초과했습니다.'
}
if (error instanceof Error && error.message) {
return error.message
}
return '파일 업로드에 실패했습니다.'
}
/**
* 선택 파일이 업로드 한도를 넘지 않는지 검사한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {string|null} 초과 시 오류 메시지, 아니면 null
*/
const findUploadSizeLimitError = (files) => {
const fileList = Array.from(files || [])
if (fileList.length > uploadMaxFileCount.value) {
return `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount.value}개입니다.`
}
const uploadTotalSize = getUploadFilesTotalSize(fileList)
if (uploadTotalSize > uploadMaxTotalSize.value) {
return `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize.value)})`
}
for (const file of fileList) {
const uploadKind = getUploadKind(file.type, file.name)
const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits.value)
if (file.size > maxBytes) {
return `파일 크기가 너무 큽니다. (최대 ${formatUploadSizeLimit(maxBytes)})`
}
}
return null
}
/**
* 미디어 파일을 업로드한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<Array<Object>>} 업로드된 파일 목록
*/
const uploadMediaFiles = async (files) => {
const sizeLimitError = findUploadSizeLimitError(files)
if (sizeLimitError) {
throw new Error(sizeLimitError)
}
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 || []
}
/**
* 업로드 파일을 현재 커서 위치에 삽입한다.
* @param {FileList|Array<File>} files - 파일 목록
* @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
* @returns {Promise<void>}
*/
const uploadAndInsert = async (files, target = 'image') => {
if (!files?.length || isUploading.value) {
return
}
isUploading.value = true
try {
const uploadedFiles = await uploadMediaFiles(files)
const imageItems = uploadedFiles.map((file) => ({
url: file.url,
useAlt: false,
caption: ''
}))
if (target === 'gallery') {
insertGallery(imageItems)
} else if (target === 'image' && imageItems[0]) {
insertImage(imageItems[0])
} else if (['video', 'audio', 'file'].includes(target) && uploadedFiles[0]) {
insertBlockSnippet(createMediaBlockMarkdown(target, uploadedFiles[0]).join('\n'))
}
if (isMediaPickerOpen.value && activeMediaPickerTab.value === 'library') {
await fetchMediaItems()
}
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
isUploading.value = false
}
}
/**
* 파일 입력 변경 처리
* @param {Event} event - 파일 입력 이벤트
* @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
* @returns {Promise<void>}
*/
const handleFileInput = async (event, target) => {
await uploadAndInsert(event.target.files, target)
event.target.value = ''
}
/**
* 미디어 모달 업로드 탭에서 파일을 업로드하고 라이브러리 목록을 갱신한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<void>}
*/
const uploadFromMediaModal = async (files) => {
if (!files?.length || isUploading.value) {
return
}
isUploading.value = true
try {
const uploadedFiles = await uploadMediaFiles(files)
const uploadedUrls = uploadedFiles
.filter(isMediaItemSelectableForTarget)
.map((file) => file.url)
selectedMediaItemMap.value = uploadedFiles.reduce((map, file) => {
if (uploadedUrls.includes(file.url)) {
map[file.url] = file
}
return map
}, {})
selectedMediaUrls.value = isGalleryMediaPicker.value
? uploadedUrls
: uploadedUrls.slice(0, 1)
activeMediaPickerTab.value = 'library'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
await fetchMediaItems().catch(() => {
mediaItems.value = [
...uploadedFiles,
...mediaItems.value
]
})
showToast('success', '업로드가 완료되어 파일이 선택되었습니다.')
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
isUploading.value = false
}
}
/**
* 미디어 모달 업로드 영역에 파일을 드롭한다.
* @param {DragEvent} event - 드롭 이벤트
* @returns {Promise<void>}
*/
const handleMediaModalDrop = async (event) => {
if (isUploading.value) {
event.preventDefault()
return
}
const files = Array.from(event.dataTransfer?.files || []).filter(isUploadFileAllowedForPicker)
if (!files.length) {
return
}
event.preventDefault()
await uploadFromMediaModal(files)
}
/**
* 미디어 모달의 삽입 대상 라벨을 반환한다.
* @returns {string} 모달 제목
*/
const mediaPickerTitle = computed(() => {
if (mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery') {
return '갤러리 이미지 선택'
}
if (mediaPickerTarget.value === 'video') {
return '비디오 선택'
}
if (mediaPickerTarget.value === 'audio') {
return '오디오 선택'
}
if (mediaPickerTarget.value === 'file') {
return '파일 선택'
}
return '이미지 선택'
})
/**
* 미디어 모달에서 다중 선택 여부를 반환한다.
* @returns {boolean} 다중 선택 여부
*/
const isGalleryMediaPicker = computed(() => mediaPickerTarget.value === 'gallery'
|| mediaPickerTarget.value === 'active-gallery')
/**
* 미디어 업로드 안내 문구를 반환한다.
* @returns {string} 안내 문구
*/
const mediaPickerUploadHint = computed(() => {
if (isGalleryMediaPicker.value) {
return `여러 이미지를 한 번에 선택할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
}
if (mediaPickerTarget.value === 'video') {
return `MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.video)})`
}
if (mediaPickerTarget.value === 'audio') {
return `MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.audio)})`
}
if (mediaPickerTarget.value === 'file') {
return `PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.document)})`
}
return `단일 이미지가 본문에 삽입됩니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
})
/**
* 붙여넣은 이미지 파일을 업로드한다.
* @param {ClipboardEvent} event - 붙여넣기 이벤트
* @returns {Promise<void>}
*/
const handlePaste = async (event) => {
if (isUploading.value) {
return
}
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
}
event.preventDefault()
await uploadAndInsert(imageFiles, imageFiles.length > 1 ? 'gallery' : 'image')
}
/**
* 드롭한 이미지 파일을 업로드한다.
* @param {DragEvent} event - 드롭 이벤트
* @returns {Promise<void>}
*/
const handleDrop = async (event) => {
if (isUploading.value) {
event.preventDefault()
return
}
const imageFiles = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
if (!imageFiles.length) {
return
}
event.preventDefault()
await uploadAndInsert(imageFiles, imageFiles.length > 1 ? 'gallery' : 'image')
}
/**
* 키보드 단축키를 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleKeydown = (event) => {
if (!(event.metaKey || event.ctrlKey)) {
return
}
const key = event.key.toLowerCase()
if (event.shiftKey && key === 'k') {
event.preventDefault()
event.stopPropagation()
deleteSelectedSourceLines()
} else if (key === 'k') {
event.preventDefault()
event.stopPropagation()
insertInlineLink()
} else if (key === 'b') {
event.preventDefault()
wrapInline('**', '**', '굵은 글씨')
} else if (key === 'i') {
event.preventDefault()
wrapInline('*', '*', '기울임')
}
}
</script>
<template>
<div ref="editorRootRef" class="admin-markdown-editor grid gap-3">
<Teleport v-if="modeToggleTeleportTo" :to="modeToggleTeleportTo">
<button
class="admin-markdown-editor__mode-toggle inline-flex size-8 shrink-0 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
type="button"
:aria-label="activeMode === 'write' ? '미리보기로 전환' : '작성 모드로 전환'"
@click="toggleEditorMode"
>
<svg
v-if="activeMode === 'write'"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="admin-markdown-editor__mode-icon lucide-book-open"
aria-hidden="true"
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="admin-markdown-editor__mode-icon lucide-edit-3"
aria-hidden="true"
>
<path d="M13 21h8" />
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
</svg>
</button>
</Teleport>
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
<div class="admin-markdown-editor__editor-surface min-h-[620px]">
<div class="admin-markdown-editor__write-columns flex items-start">
<div
ref="gutterRef"
class="admin-markdown-editor__gutter w-10 shrink-0 select-none py-5 pr-2 font-mono text-[13px] leading-7 text-[#a0a8b0]"
aria-hidden="true"
>
<div
v-for="ln in gutterLineCount"
:key="`gutter-line-${ln}`"
class="admin-markdown-editor__gutter-line text-right tabular-nums"
:style="{ height: `${getGutterLineHeight(ln - 1)}px` }"
>
{{ ln }}
</div>
</div>
<textarea
ref="textareaRef"
v-model="markdownValue"
class="admin-markdown-editor__textarea min-h-[620px] min-w-0 flex-1 resize-none overflow-hidden break-words border-0 bg-transparent py-5 pl-0 pr-5 text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
placeholder="마크다운으로 글을 작성하세요."
spellcheck="false"
@keydown="handleKeydown"
@paste="handlePaste"
@drop="handleDrop"
@dragover.prevent
@input="refreshCaretLogicalLine"
@compositionstart="handleTextareaCompositionStart"
@compositionend="handleTextareaCompositionEnd"
@click="refreshCaretLogicalLine"
@keyup="refreshCaretLogicalLine"
@select="refreshCaretLogicalLine"
@focus="onTextareaFocus"
@blur="onTextareaBlur"
/>
</div>
</div>
<div v-if="isUploading" class="admin-markdown-editor__uploading absolute right-3 top-3 rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white">
업로드 중
</div>
</div>
<div
v-else
ref="previewRef"
class="admin-markdown-editor__preview min-h-[620px] px-0 py-5 text-[#15171a] outline-none"
style="--site-text: #15171a; --site-muted: #6b7280; --site-panel: #f6f7f8; --site-line: #e3e6e8; --site-accent: #2eb6ea;"
tabindex="0"
@keydown.capture="handlePreviewKeydownCapture"
>
<ContentMarkdownRenderer
ref="previewRendererRef"
:content="markdownValue"
interactive
:slash-menu-active="liveSlashVisible"
:slash-suppressed-lines="liveSlashSuppressedLineList"
@gallery-reorder="onPreviewGalleryReorder"
@merge-images-to-gallery="onPreviewMergeImagesToGallery"
@insert-image-to-gallery="onPreviewInsertImageToGallery"
@extract-gallery-image="onPreviewExtractGalleryImage"
@remove-gallery-image="onPreviewRemoveGalleryImage"
@block-content-change="onPreviewBlockContentChange"
@content-replace="onPreviewContentReplace"
@append-paragraph="onPreviewAppendParagraph"
@insert-after-line="onPreviewInsertAfterLine"
@delete-line="onPreviewDeleteLine"
@merge-with-previous-line="onPreviewMergeWithPreviousLine"
@line-focus="handleLiveLineFocus"
@line-blur="handleLiveLineBlur"
@slash-update="onLiveSlashUpdate"
@slash-end="onLiveSlashEnd"
@slash-apply="onLiveSlashApply"
/>
</div>
<Teleport to="body">
<div
v-if="liveSlashVisible"
ref="liveSlashMenuRef"
class="admin-markdown-editor__slash-menu fixed z-[60] w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded-lg border border-[#e3e6e8] bg-white p-1 shadow-lg"
:style="{ top: `${liveSlashMenuPosition.top}px`, left: `${liveSlashMenuPosition.left}px` }"
role="listbox"
aria-label="슬래시 명령"
>
<button
v-for="(command, commandIndex) in visibleLiveSlashCommands"
:key="`live-slash-${command.id}`"
class="admin-markdown-editor__slash-item flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left transition-colors"
:class="commandIndex === liveSlashHighlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
type="button"
role="option"
:aria-selected="commandIndex === liveSlashHighlightedIndex"
@mouseenter="onSlashMenuItemMouseEnter(commandIndex)"
@mousedown.prevent="applyLiveSlashCommand(command)"
>
<span class="admin-markdown-editor__slash-leading flex min-w-0 items-center gap-2.5">
<AdminSlashCommandIcon
:command-id="command.id"
class="admin-markdown-editor__slash-icon shrink-0 text-[#394047]"
/>
<span class="admin-markdown-editor__slash-label truncate text-sm text-[#15171a]">{{ command.label }}</span>
</span>
<span
class="admin-markdown-editor__slash-slug shrink-0 text-xs text-[#8b959e] transition-opacity"
:class="commandIndex === liveSlashHighlightedIndex ? 'opacity-100' : 'opacity-0'"
>/{{ command.id }}</span>
</button>
<p v-if="!visibleLiveSlashCommands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
일치하는 명령이 없습니다.
</p>
</div>
</Teleport>
<div v-if="isMediaPickerOpen" class="admin-markdown-editor__media-modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="closeMediaPicker">
<div class="admin-markdown-editor__media-panel flex h-[min(86vh,760px)] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
<header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
<div>
<h2 class="admin-markdown-editor__media-title text-lg font-bold text-black">
{{ mediaPickerTitle }}
</h2>
</div>
<button
class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
닫기
</button>
</header>
<div class="admin-markdown-editor__media-tabs flex items-center gap-3 border-b border-[#e3e6e8] px-5">
<div class="flex min-w-0 flex-1 items-center">
<button
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'upload'"
>
업로드
</button>
<button
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'library'"
>
미디어 라이브러리
</button>
</div>
<label v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-search mb-px flex w-full max-w-xs shrink-0 items-center py-2">
<span class="sr-only">파일명 검색</span>
<input
:value="mediaSearchInput"
class="admin-markdown-editor__media-search-input w-full rounded border border-[#d7dde2] bg-white px-3 py-1.5 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]/20"
type="search"
placeholder="파일명 검색"
autocomplete="off"
@input="onMediaSearchInput"
@compositionupdate="onMediaSearchInput"
@compositionend="onMediaSearchCompositionEnd"
>
</label>
</div>
<div class="admin-markdown-editor__media-body min-h-0 flex-1 overflow-y-auto p-5">
<template v-if="activeMediaPickerTab === 'library'">
<div v-if="isLoadingMedia" class="admin-markdown-editor__media-loading py-12 text-center text-sm text-[#8e9cac]">
불러오는 중
</div>
<div v-else-if="filteredMediaItems.length" class="admin-markdown-editor__media-grid grid grid-cols-2 gap-3 md:grid-cols-4">
<button
v-for="item in filteredMediaItems"
:key="item.url"
class="admin-markdown-editor__media-item group relative overflow-hidden rounded border bg-white text-left transition"
:class="selectedMediaUrls.includes(item.url) ? 'border-[3px] border-[#ff7a00] ring-2 ring-[#ff7a00]/40' : 'border border-[#e3e6e8] hover:border-[#8e9cac]'"
type="button"
@click="toggleMediaSelection(item)"
>
<span
v-if="getGallerySelectionOrder(item.url)"
class="admin-markdown-editor__media-selection-order absolute right-2 top-2 z-10 grid size-7 place-items-center rounded-full bg-[#ff7a00] text-xs font-bold text-white shadow-sm ring-2 ring-white"
>
{{ getGallerySelectionOrder(item.url) }}
</span>
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
:src="getMediaPickerPreviewUrl(item)"
:alt="item.name || ''"
loading="lazy"
decoding="async"
@error="onMediaPickerImageError($event, item)"
>
<span
v-else
class="admin-markdown-editor__media-thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
>
{{ getMediaItemKind(item) }}
</span>
<span class="admin-markdown-editor__media-name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
{{ item.name || item.url }}
</span>
</button>
</div>
<div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
<template v-if="mediaSearchQuery.trim()">
「{{ mediaSearchQuery.trim() }}」에 맞는 미디어가 없습니다.
</template>
<template v-else>
선택할 미디어가 없습니다. 업로드 탭에서 파일을 추가할 수 있습니다.
</template>
</div>
</template>
<div
v-else
class="admin-markdown-editor__media-upload-zone relative grid h-full min-h-[420px] place-items-center overflow-hidden rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
:class="isUploading ? 'admin-markdown-editor__media-upload-zone--uploading select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
@dragover.prevent
@drop.prevent="handleMediaModalDrop"
>
<div class="admin-markdown-editor__media-upload-inner grid gap-3 px-6">
<p class="admin-markdown-editor__media-upload-title text-lg font-semibold text-[#15171a]">
{{ isUploading ? '업로드 중입니다' : '파일을 끌어 업로드' }}
</p>
<p class="admin-markdown-editor__media-upload-or text-sm text-[#6b7280]">
{{ isUploading ? '잠시만 기다려 주세요.' : '또는' }}
</p>
<label
class="admin-markdown-editor__media-upload-button mx-auto inline-flex h-10 items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50"
:class="isUploading ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
>
{{ isUploading ? '업로드 중' : '파일 선택' }}
<input
class="sr-only"
type="file"
:accept="mediaPickerAccept"
:multiple="isGalleryMediaPicker"
:disabled="isUploading"
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
>
</label>
<p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
{{ mediaPickerUploadHint }}
</p>
</div>
<div
v-if="isUploading"
class="admin-markdown-editor__media-upload-overlay pointer-events-none absolute inset-0 grid place-items-center bg-white/72 backdrop-blur-[1px]"
aria-live="polite"
>
<div class="admin-markdown-editor__media-upload-loading grid w-full max-w-sm gap-4 px-8 text-center">
<span class="admin-markdown-editor__media-upload-spinner mx-auto size-8 rounded-full border-2 border-[#d7dde2] border-t-[#15171a]" aria-hidden="true" />
<strong class="text-sm font-semibold text-[#15171a]">업로드 중입니다</strong>
<span class="admin-markdown-editor__media-upload-skeleton h-2 overflow-hidden rounded-full bg-[#e3e6e8]" aria-hidden="true" />
</div>
</div>
</div>
</div>
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex flex-wrap items-center justify-between gap-3 border-t border-[#e3e6e8] px-5 py-4">
<div class="admin-markdown-editor__media-pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-[#6b7280]">
<span>{{ mediaPickerRangeLabel }}</span>
<nav v-if="mediaPickerPageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="미디어 선택 페이지">
<button
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoadingMedia || mediaPickerPage <= 1"
aria-label="이전 페이지"
@click="setMediaPickerPage(mediaPickerPage - 1)"
>
‹
</button>
<template v-for="item in mediaPickerPageItems" :key="item">
<span v-if="typeof item === 'string'" class="px-1 text-[#8e9cac]">...</span>
<button
v-else
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full text-xs font-semibold transition"
:class="item === mediaPickerPage ? 'bg-[#15171a] text-white' : 'text-[#8e9cac] hover:bg-[#f1f3f5] hover:text-[#394047]'"
type="button"
:disabled="isLoadingMedia"
@click="setMediaPickerPage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoadingMedia || mediaPickerPage >= mediaPickerPageCount"
aria-label="다음 페이지"
@click="setMediaPickerPage(mediaPickerPage + 1)"
>
›
</button>
</nav>
</div>
<div class="admin-markdown-editor__media-actions flex items-center justify-end gap-2">
<button
class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
취소
</button>
<button
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading || selectedMediaUrls.length === 0"
@click="applyMediaSelection"
>
삽입
</button>
</div>
</footer>
</div>
</div>
</div>
<AdminToast :toast="toast" />
</template>
<style scoped>
.admin-markdown-editor__gutter {
scrollbar-width: none;
}
.admin-markdown-editor__gutter::-webkit-scrollbar {
display: none;
}
.admin-markdown-editor__media-upload-zone--uploading::before {
position: absolute;
inset: 0;
content: '';
background: linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.72) 45%, transparent 72%);
transform: translateX(-100%);
animation: admin-markdown-editor-upload-sheen 1.4s ease-in-out infinite;
}
.admin-markdown-editor__media-upload-spinner {
animation: admin-markdown-editor-upload-spin 0.8s linear infinite;
}
.admin-markdown-editor__media-upload-skeleton::before {
display: block;
width: 40%;
height: 100%;
content: '';
border-radius: inherit;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.86), transparent);
animation: admin-markdown-editor-upload-skeleton 1.15s ease-in-out infinite;
}
@keyframes admin-markdown-editor-upload-spin {
to {
transform: rotate(360deg);
}
}
@keyframes admin-markdown-editor-upload-sheen {
to {
transform: translateX(100%);
}
}
@keyframes admin-markdown-editor-upload-skeleton {
from {
transform: translateX(-120%);
}
to {
transform: translateX(260%);
}
}
</style>