4181 lines
126 KiB
Vue
4181 lines
126 KiB
Vue
<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, 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',
|
||
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 }>} 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>
|
||
|
||
<div
|
||
v-if="toast"
|
||
class="admin-markdown-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||
:class="{
|
||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||
'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
|
||
}"
|
||
role="status"
|
||
>
|
||
{{ toast.message }}
|
||
</div>
|
||
</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>
|