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

1394 lines
38 KiB
Vue

<script setup>
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
import {
convertHtmlToMarkdown,
readEditableTextFromElement
} from '../../lib/markdown-inline.js'
import {
getSelectionOffsetInElement
} from '../../lib/markdown-live-selection.js'
import {
getMarkdownLineIndexAtOffset,
getMarkdownLineStartOffset,
getMarkdownSelectionState
} from '../../lib/markdown-source-selection.js'
import {
getEditorHistoryKeyboardAction,
isEditorModeToggleShortcut,
isMediaPickerCloseShortcut
} from '../../lib/markdown-editor-keyboard.js'
import {
isCodeFenceBodyLine,
resolvePreviewFocusPosition as resolveMarkdownPreviewFocusPosition
} from '../../lib/markdown-editor-focus.js'
import {
filterImageUploadFiles,
resolveImageUploadInsertTarget,
resolveUploadFetchErrorMessage
} from '../../lib/markdown-editor-media.js'
import { parseEditorImageLine } from '../../lib/markdown-editor-preview-blocks.js'
import {
getNextSlashHighlightedIndex,
getSlashMenuKeyboardAction,
parseSlashInput,
shouldCaptureSlashMenuKeyboardAction
} from '../../lib/markdown-slash-commands.js'
import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSlashMenu.js'
import { useAdminMarkdownSlashMenuPosition } from '../../composables/useAdminMarkdownSlashMenuPosition.js'
import { useAdminMarkdownSlashCommandActions } from '../../composables/useAdminMarkdownSlashCommandActions.js'
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
import { useAdminMarkdownEditorFocusActions } from '../../composables/useAdminMarkdownEditorFocusActions.js'
import { useAdminMarkdownSourceActions } from '../../composables/useAdminMarkdownSourceActions.js'
import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js'
import { useAdminMarkdownMediaActions } from '../../composables/useAdminMarkdownMediaActions.js'
import { useAdminMarkdownFocusBridge } from '../../composables/useAdminMarkdownFocusBridge.js'
import { useAdminMarkdownPreviewFocus } from '../../composables/useAdminMarkdownPreviewFocus.js'
import { useAdminMarkdownTextareaFocus } from '../../composables/useAdminMarkdownTextareaFocus.js'
import { useAdminMarkdownBlockPanel } from '../../composables/useAdminMarkdownBlockPanel.js'
import { useAdminMarkdownBlockActions } from '../../composables/useAdminMarkdownBlockActions.js'
import { useAdminMarkdownPreviewActions } from '../../composables/useAdminMarkdownPreviewActions.js'
import {
buildDefaultUploadSizeLimits,
DEFAULT_UPLOAD_MAX_FILE_COUNT,
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
normalizeUploadMaxFileCount,
normalizeUploadMaxTotalSize
} from '../../lib/upload-size-limit.js'
import AdminMarkdownModeToggle from './AdminMarkdownModeToggle.vue'
import AdminMarkdownPreviewSurface from './AdminMarkdownPreviewSurface.vue'
import AdminMarkdownSlashMenu from './AdminMarkdownSlashMenu.vue'
import AdminMarkdownWriteSurface from './AdminMarkdownWriteSurface.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 writeSurfaceRef = ref(null)
const previewSurfaceRef = ref(null)
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
const activeLogicalLineIndex = ref(0)
/** 소스 모드 입력 이벤트 직후 textarea의 최신 본문 값 */
const writeModeContextValue = ref(null)
/** textarea 자동 줄바꿈을 반영한 논리 줄별 표시 높이(px) */
const gutterLineHeights = ref([])
const lastSelectionState = ref({
start: 0,
end: 0
})
const {
liveSlashSourceLine,
liveSlashQuery,
liveSlashHighlightedIndex,
liveSlashMenuPosition,
liveSlashMenuRef,
liveSlashInsertLine,
liveSlashKeyboardNav,
liveSlashVisible,
liveSlashSuppressedLineList,
visibleLiveSlashCommands,
clearLiveSlashState,
openLiveSlashMenu,
isLiveSlashLineSuppressed,
suppressLiveSlashLine,
releaseLiveSlashLine,
resetLiveSlashHighlightedIndex,
clampLiveSlashHighlightedIndex,
startLiveSlashKeyboardNavigation,
stopLiveSlashKeyboardNavigation
} = useAdminMarkdownSlashMenu()
const {
mediaItems,
isMediaPickerOpen,
isLoadingMedia,
isUploading,
mediaPickerTarget,
activeMediaPickerTab,
mediaSearchQuery,
mediaSearchInput,
onMediaSearchInput,
onMediaSearchCompositionEnd,
mediaPickerPage,
selectedMediaUrls,
mediaPickerAccept,
mediaPickerTitle,
isGalleryMediaPicker,
mediaPickerPageCount,
filteredMediaItems,
mediaPickerRangeLabel,
mediaPickerPageItems,
mediaPickerUploadHint,
mediaPickerEmptyMessage,
mediaPickerApplyState,
isMediaItemSelectableForCurrentTarget,
isUploadFileAllowedForPicker,
openMediaPickerState,
closeMediaPickerState,
fetchMediaItems,
uploadMediaFilesWithState,
uploadFilesIntoMediaPicker,
setMediaPickerPage: setMediaPickerPageState,
toggleMediaSelection,
resolveCurrentSelectedMediaItems,
getGallerySelectionOrder
} = useAdminMarkdownMediaPicker({
uploadSizeLimits,
uploadMaxFileCount,
uploadMaxTotalSize
})
/**
* 작성 표면의 textarea DOM을 반환한다.
* @returns {HTMLTextAreaElement|null} textarea DOM
*/
const getWriteTextareaElement = () => writeSurfaceRef.value?.getTextareaElement?.() ?? null
const {
focusPreviewEditableAtLine,
getCurrentPreviewSourceLine,
getPreviewSourceLineElement,
getPreviewSurfaceElement,
scrollPreviewToTop,
selectAllPreviewDocument
} = useAdminMarkdownPreviewFocus({
previewSurfaceRef
})
const markdownValue = computed({
get: () => normalizeMarkdownContent(props.modelValue),
set: (value) => emit('update:modelValue', value)
})
const {
updateLiveSlashMenuPosition
} = useAdminMarkdownSlashMenuPosition({
activeMode,
markdownValue,
liveSlashSourceLine,
liveSlashQuery,
liveSlashHighlightedIndex,
liveSlashMenuPosition,
liveSlashMenuRef,
liveSlashVisible,
visibleLiveSlashCommands,
resetLiveSlashHighlightedIndex,
clampLiveSlashHighlightedIndex,
getPreviewSourceLineElement,
getPreviewSurfaceElement,
getWriteTextareaElement
})
watch(markdownValue, () => {
const textarea = getWriteTextareaElement()
if (activeMode.value !== 'write'
|| !textarea
|| (import.meta.client && document.activeElement !== textarea)) {
writeModeContextValue.value = null
}
})
/**
* 오프셋이 속한 마크다운 줄 번호를 반환한다.
* @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 = getWriteTextareaElement()
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
}
}
const {
isBlockPanelEngaged,
inlineColorSelectionContext,
activeBlockContext,
activeMediaBlock,
activePanelContext,
isBlockPanelVisible,
ensureBlockPanelEngaged,
closeBlockPanel,
syncBlockPanelState,
syncInlineColorSelectionContext,
handleTextareaFocusIn,
handleTextareaFocusOut,
handleBlockPanelFocusIn,
handleBlockPanelFocusOut,
handleTextareaCompositionStart,
handleTextareaCompositionEnd: markTextareaCompositionEnd,
handleLiveLineFocus,
handleLiveLineBlur,
resetBlockPanelContext,
clearBlockPanelFocusTimer
} = useAdminMarkdownBlockPanel({
activeMode,
value: markdownValue,
writeModeContextValue,
activeLogicalLineIndex,
isMediaPickerOpen,
resolveBlockContext: resolveActiveBlockContext,
getInlineColorSelectionContext: () => getSourceInlineColorSelectionContext() || getLiveInlineColorSelectionContext(),
emitBlockPanel: (payload) => emit('block-panel', payload)
})
/**
* 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
* @returns {number}
*/
const gutterLineCount = computed(() => {
const raw = markdownValue.value ?? ''
const n = raw.split('\n').length
return Math.max(1, n)
})
/**
* 소스 textarea의 줄 시작 오프셋을 반환한다.
* @param {number} lineIndex - 줄 번호(0-based)
* @returns {number} 문자 오프셋
*/
const getLineStartOffset = (lineIndex) => {
return getMarkdownLineStartOffset(markdownValue.value, lineIndex)
}
/**
* 문자 오프셋이 속한 소스 줄 번호를 반환한다.
* @param {number} offset - 문자 오프셋
* @returns {number} 줄 번호(0-based)
*/
const getLineIndexAtOffset = (offset) => {
return getMarkdownLineIndexAtOffset(markdownValue.value, offset)
}
const {
getGutterLineHeight,
getNearestScrollContainer,
syncGutterLineHeights,
syncTextareaHeight,
refreshCaretLogicalLine,
rememberTextareaSelection,
focusTextareaAtLine,
restoreTextareaFocus
} = useAdminMarkdownTextareaFocus({
value: markdownValue,
activeMode,
writeModeContextValue,
lastSelectionState,
activeLogicalLineIndex,
gutterLineHeights,
editorRootRef,
minTextareaHeight: 620,
getWriteTextareaElement,
getLineStartOffset,
syncBlockPanelState,
syncWriteSlashState: () => syncWriteSlashState()
})
/**
* 라이브 모드에 실제로 존재하는 편집 줄로 포커스 대상을 보정한다.
* @param {number} line - 원본 줄 번호
* @param {number} offset - 원본 줄 내부 오프셋
* @returns {{ line: number, offset: number }} 라이브 포커스 대상
*/
const resolvePreviewFocusPosition = (line, offset) => {
return resolveMarkdownPreviewFocusPosition({
value: markdownValue.value,
line,
offset
})
}
/**
* textarea 포커스 진입
* @returns {void}
*/
const onTextareaFocus = () => {
handleTextareaFocusIn()
refreshCaretLogicalLine()
}
/**
* textarea 포커스 이탈(블록 패널으로 이동 시 유지)
* @returns {void}
*/
const onTextareaBlur = () => {
handleTextareaFocusOut()
}
/**
* IME 조합 입력 종료 후 실제 커서 줄 기준으로 패널을 다시 동기화한다.
* @returns {void}
*/
const handleTextareaCompositionEnd = () => {
markTextareaCompositionEnd()
refreshCaretLogicalLine()
nextTick(syncBlockPanelState)
}
/**
* 소스(작성) 모드 textarea 현재 줄의 슬래시 입력 상태를 갱신한다.
* @returns {void}
*/
const syncWriteSlashState = () => {
if (activeMode.value !== 'write') {
return
}
const line = activeLogicalLineIndex.value
const lines = (writeModeContextValue.value ?? markdownValue.value ?? '').split('\n')
const lineValue = lines[line] ?? ''
if (isLiveSlashLineSuppressed(line, lineValue)) {
if (liveSlashSourceLine.value !== null) {
clearLiveSlashState()
}
return
}
releaseLiveSlashLine(line)
if (isCodeFenceBodyLine(lines, line)) {
onLiveSlashEnd({ sourceLine: line })
return
}
const parsed = parseSlashInput(lines[line] ?? '')
if (!parsed) {
onLiveSlashEnd({ sourceLine: line })
return
}
openLiveSlashMenu(line, parsed.query)
nextTick(updateLiveSlashMenuPosition)
}
watch(() => props.modelValue, () => {
if (isBlockPanelEngaged.value) {
syncBlockPanelState()
return
}
refreshCaretLogicalLine()
})
watch(markdownValue, (nextValue, previousValue) => {
if (nextValue === previousValue) {
return
}
recordEditorHistory(previousValue)
})
const {
rememberWritePositionForPreview,
rememberPreviewPosition,
requestPreviewFocus
} = useAdminMarkdownFocusBridge({
activeMode,
value: markdownValue,
lastSelectionState,
activeLogicalLineIndex,
getWriteTextareaElement,
getLineIndexAtOffset,
getLineStartOffset,
resolvePreviewFocusPosition,
getCurrentPreviewSourceLine,
syncTextareaHeight,
focusTextareaAtLine,
restoreTextareaFocus,
scrollPreviewToTop,
focusPreviewEditableAtLine
})
/**
* 작성 모드와 미리보기 모드를 전환한다.
* @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 = getWriteTextareaElement()
if (!textarea || document.activeElement !== textarea) {
return
}
refreshCaretLogicalLine()
}
/**
* 에디터 안에서 Cmd/Ctrl+E를 누르면 작성/미리보기 모드를 전환한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const onDocumentKeydown = (event) => {
if (isMediaPickerCloseShortcut(event) && isMediaPickerOpen.value) {
event.preventDefault()
closeMediaPicker()
return
}
const root = editorRootRef.value
if (!root || !root.contains(document.activeElement)) {
return
}
const historyKeyboardAction = getEditorHistoryKeyboardAction(event)
if (historyKeyboardAction) {
event.preventDefault()
event.stopPropagation()
restoreEditorHistory(historyKeyboardAction)
return
}
if (liveSlashVisible.value) {
const slashKeyboardAction = getSlashMenuKeyboardAction(event.key)
const slashCommandCount = visibleLiveSlashCommands.value.length
if (slashKeyboardAction === 'navigate') {
if (!shouldCaptureSlashMenuKeyboardAction({
action: slashKeyboardAction,
commandCount: slashCommandCount
})) {
return
}
event.preventDefault()
event.stopPropagation()
startLiveSlashKeyboardNavigation()
liveSlashHighlightedIndex.value = getNextSlashHighlightedIndex({
currentIndex: liveSlashHighlightedIndex.value,
commandCount: slashCommandCount,
key: event.key
})
return
}
if (slashKeyboardAction === 'apply') {
const command = visibleLiveSlashCommands.value[liveSlashHighlightedIndex.value]
if (!shouldCaptureSlashMenuKeyboardAction({
action: slashKeyboardAction,
commandCount: slashCommandCount
}) || !command) {
clearLiveSlashState()
return
}
event.preventDefault()
event.stopPropagation()
applyLiveSlashCommand(command)
return
}
if (slashKeyboardAction === 'cancel') {
event.preventDefault()
event.stopPropagation()
cancelLiveSlashCommand()
return
}
}
if (!isEditorModeToggleShortcut(event)) {
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(() => {
clearBlockPanelFocusTimer()
stopLiveSlashKeyboardNavigation()
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('keydown', onDocumentKeydown, true)
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
window.removeEventListener('resize', syncGutterLineHeights)
})
refreshCaretLogicalLine()
syncGutterLineHeights()
syncBlockPanelState()
})
/**
* textarea 선택 영역 정보를 반환한다.
* @returns {{ start: number, end: number, value: string }} 선택 정보
*/
const getSelectionState = () => {
return getMarkdownSelectionState({
value: markdownValue.value,
textarea: getWriteTextareaElement()
})
}
/**
* 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: getWriteTextareaElement()?.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 = getWriteTextareaElement()
if (!textarea) {
return
}
textarea.focus()
textarea.setSelectionRange(start, end)
restoreScroll(textarea)
if (import.meta.client) {
window.requestAnimationFrame(() => restoreScroll(textarea))
}
refreshCaretLogicalLine()
})
}
const {
focusFirstBlock,
restoreEditorFocusAfterHistory
} = useAdminMarkdownEditorFocusActions({
activeMode,
value: markdownValue,
activeLogicalLineIndex,
getWriteTextareaElement,
getLineIndexAtOffset,
getLineStartOffset,
setTextareaSelection,
focusTextareaAtLine,
refreshCaretLogicalLine,
scrollPreviewToTop,
focusPreviewEditableAtLine
})
const {
recordEditorHistory,
restoreEditorHistory
} = useAdminMarkdownHistory({
value: markdownValue,
lastSelectionState,
restoreFocusAfterHistory: restoreEditorFocusAfterHistory
})
const {
replaceSelection,
insertBlockSnippet,
wrapInline,
insertInlineLink,
deleteSelectedSourceLines
} = useAdminMarkdownSourceActions({
value: markdownValue,
getSelectionState,
setTextareaSelection
})
/**
* 미디어 선택 모달 파생 이미지 로드 실패 시 원본 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 {number} startLine - 시작 줄
* @param {number} endLine - 끝 줄
* @param {string[]} replacementLines - 대체 줄 목록
* @param {boolean} focusEditor - 교체 후 textarea에 포커스를 돌릴지 여부
* @returns {void}
*/
const replaceLineRange = (startLine, endLine, replacementLines, focusEditor = true) => {
const lines = (markdownValue.value || '').split('\n')
const nextLines = [
...lines.slice(0, startLine),
...replacementLines,
...lines.slice(endLine + 1)
]
markdownValue.value = nextLines.join('\n')
if (focusEditor) {
setTextareaSelection(nextLines.slice(0, startLine + replacementLines.length).join('\n').length)
}
}
/**
* 줄 범위 교체 계획을 현재 마크다운 본문에 적용한다.
* @param {{ startLine: number, endLine: number, replacementLines: string[] }|null} plan - 교체 계획
* @param {boolean} focusEditor - 교체 후 textarea에 포커스를 돌릴지 여부
* @returns {boolean} 적용 여부
*/
const applyLineReplacementPlan = (plan, focusEditor = false) => {
if (!plan) {
return false
}
replaceLineRange(plan.startLine, plan.endLine, plan.replacementLines, focusEditor)
return true
}
/**
* 현재 문서 기준 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
}
const {
onPreviewAppendParagraph,
onPreviewBlockContentChange,
onPreviewContentReplace,
onPreviewDeleteLine,
onPreviewExtractGalleryImage,
onPreviewGalleryReorder,
onPreviewInsertAfterLine,
onPreviewInsertImageToGallery,
onPreviewMergeImagesToGallery,
onPreviewMergeWithPreviousLine,
onPreviewRemoveGalleryImage
} = useAdminMarkdownPreviewActions({
value: markdownValue,
activeLogicalLineIndex,
liveSlashInsertLine,
replaceLineRange,
resolveCurrentFencedBlockEndLine,
resetBlockPanelContext,
focusPreviewEditableAtLine
})
/**
* 라이브 모드 미리보기 루트 단축키를 처리한다.
* @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()
selectAllPreviewDocument()
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)
}
/**
* 슬래시 메뉴 항목 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
}
const lines = (markdownValue.value ?? '').split('\n')
const lineValue = lines[payload.sourceLine] ?? ''
if (isLiveSlashLineSuppressed(payload.sourceLine, lineValue)) {
if (liveSlashSourceLine.value !== null) {
clearLiveSlashState()
}
return
}
releaseLiveSlashLine(payload.sourceLine)
if (isCodeFenceBodyLine(lines, payload.sourceLine)) {
onLiveSlashEnd({ sourceLine: payload.sourceLine })
return
}
openLiveSlashMenu(payload.sourceLine, payload.query)
nextTick(updateLiveSlashMenuPosition)
}
const {
insertGallery,
applyMediaSelection,
uploadAndInsert,
uploadFromMediaModal
} = useAdminMarkdownMediaActions({
value: markdownValue,
mediaPickerTarget,
liveSlashInsertLine,
isUploading,
isMediaPickerOpen,
activeMediaPickerTab,
resolveCurrentSelectedMediaItems,
uploadMediaFilesWithState,
uploadFilesIntoMediaPicker,
fetchMediaItems,
closeMediaPicker: () => closeMediaPicker(),
insertBlockSnippet,
appendImagesToActiveGallery: (images) => appendImagesToActiveGallery(images),
showToast,
resolveUploadFetchErrorMessage
})
const {
appendImagesToActiveGallery,
moveActiveGalleryImage,
removeActiveMediaImage,
setActiveMediaUseAlt,
updateActiveCalloutOptions,
updateActiveCodeOptions,
updateActiveEmbedUrl,
updateActiveInlineColor,
updateActiveMediaImage,
updateActiveQuoteBackground,
updateActiveToggleOptions
} = useAdminMarkdownBlockActions({
value: markdownValue,
activeMediaBlock,
activeBlockContext,
inlineColorSelectionContext,
applyLineReplacementPlan,
ensureBlockPanelEngaged,
insertGallery,
setTextareaSelection,
requestPreviewFocus,
focusPreviewEditableAtLine
})
/**
* 미디어 선택 창을 연다.
* @param {'image'|'gallery'|'active-gallery'} target - 삽입 대상
* @returns {Promise<void>}
*/
const openMediaPicker = async (target) => {
if (target === 'active-gallery') {
ensureBlockPanelEngaged()
}
openMediaPickerState(target)
}
defineExpose({
focusFirstBlock,
toggleEditorMode,
activeMode,
handleBlockPanelFocusIn,
handleBlockPanelFocusOut,
updateActiveMediaImage,
setActiveMediaUseAlt,
moveActiveGalleryImage,
removeActiveMediaImage,
appendImagesToActiveGallery,
updateActiveEmbedUrl,
updateActiveQuoteBackground,
updateActiveCalloutOptions,
updateActiveCodeOptions,
updateActiveToggleOptions,
updateActiveInlineColor,
openMediaPicker
})
/**
* 미디어 선택 창을 닫는다.
* @returns {void}
*/
const closeMediaPicker = () => {
if (!closeMediaPickerState()) {
return
}
liveSlashInsertLine.value = null
}
const {
applyLiveSlashCommand,
cancelLiveSlashCommand,
onLiveSlashApply,
onLiveSlashEnd
} = useAdminMarkdownSlashCommandActions({
activeMode,
markdownValue,
liveSlashSourceLine,
liveSlashQuery,
liveSlashInsertLine,
clearLiveSlashState,
releaseLiveSlashLine,
suppressLiveSlashLine,
replaceLineRange,
openMediaPicker,
setTextareaSelection,
focusPreviewEditableAtLine
})
/**
* 미디어 선택 모달 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {Promise<void>}
*/
const setMediaPickerPage = async (page) => {
setMediaPickerPageState(page)
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 {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 {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)
}
/**
* 붙여넣은 이미지 파일을 업로드한다.
* @param {ClipboardEvent} event - 붙여넣기 이벤트
* @returns {Promise<void>}
*/
const handlePaste = async (event) => {
if (isUploading.value) {
return
}
const imageFiles = filterImageUploadFiles(event.clipboardData?.files)
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, resolveImageUploadInsertTarget(imageFiles))
}
/**
* 드롭한 이미지 파일을 업로드한다.
* @param {DragEvent} event - 드롭 이벤트
* @returns {Promise<void>}
*/
const handleDrop = async (event) => {
if (isUploading.value) {
event.preventDefault()
return
}
const imageFiles = filterImageUploadFiles(event.dataTransfer?.files)
if (!imageFiles.length) {
return
}
event.preventDefault()
await uploadAndInsert(imageFiles, resolveImageUploadInsertTarget(imageFiles))
}
/**
* 키보드 단축키를 처리한다.
* @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">
<AdminMarkdownModeToggle
:active-mode="activeMode"
:teleport-to="modeToggleTeleportTo"
@toggle="toggleEditorMode"
/>
<AdminMarkdownWriteSurface
v-if="activeMode === 'write'"
ref="writeSurfaceRef"
v-model="markdownValue"
:gutter-line-count="gutterLineCount"
:get-gutter-line-height="getGutterLineHeight"
:is-uploading="isUploading"
@keydown="handleKeydown"
@paste="handlePaste"
@drop="handleDrop"
@input="refreshCaretLogicalLine"
@compositionstart="handleTextareaCompositionStart"
@compositionend="handleTextareaCompositionEnd"
@click="refreshCaretLogicalLine"
@keyup="refreshCaretLogicalLine"
@select="refreshCaretLogicalLine"
@focus="onTextareaFocus"
@blur="onTextareaBlur"
/>
<AdminMarkdownPreviewSurface
v-else
ref="previewSurfaceRef"
:content="markdownValue"
:slash-menu-active="liveSlashVisible"
:slash-suppressed-lines="liveSlashSuppressedLineList"
@keydown="handlePreviewKeydownCapture"
@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"
/>
<Teleport to="body">
<AdminMarkdownSlashMenu
v-if="liveSlashVisible"
ref="liveSlashMenuRef"
:commands="visibleLiveSlashCommands"
:highlighted-index="liveSlashHighlightedIndex"
:position="liveSlashMenuPosition"
@highlight="onSlashMenuItemMouseEnter"
@select="applyLiveSlashCommand"
/>
</Teleport>
<AdminMarkdownMediaPickerModal
v-if="isMediaPickerOpen"
:title="mediaPickerTitle"
:active-tab="activeMediaPickerTab"
:search-input="mediaSearchInput"
:media-items="filteredMediaItems"
:selected-urls="selectedMediaUrls"
:is-loading="isLoadingMedia"
:is-uploading="isUploading"
:accept="mediaPickerAccept"
:multiple="isGalleryMediaPicker"
:upload-hint="mediaPickerUploadHint"
:empty-message="mediaPickerEmptyMessage"
:range-label="mediaPickerRangeLabel"
:page="mediaPickerPage"
:page-count="mediaPickerPageCount"
:page-items="mediaPickerPageItems"
:apply-state="mediaPickerApplyState"
:get-selection-order="getGallerySelectionOrder"
@apply="applyMediaSelection"
@close="closeMediaPicker"
@drop="handleMediaModalDrop"
@image-error="onMediaPickerImageError($event.event, $event.item)"
@page="setMediaPickerPage"
@search-composition-end="onMediaSearchCompositionEnd"
@search-input="onMediaSearchInput"
@tab="activeMediaPickerTab = $event"
@toggle="toggleMediaSelection"
@upload="uploadFromMediaModal"
/>
</div>
<AdminToast :toast="toast" />
</template>