2460 lines
65 KiB
Vue
2460 lines
65 KiB
Vue
<script setup>
|
|
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
|
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
|
|
import {
|
|
serializeImageMarkdown
|
|
} from '../../lib/markdown-image.js'
|
|
import {
|
|
convertHtmlToMarkdown,
|
|
readEditableTextFromElement
|
|
} from '../../lib/markdown-inline.js'
|
|
import {
|
|
getSelectionOffsetInElement
|
|
} from '../../lib/markdown-live-selection.js'
|
|
import {
|
|
deleteMarkdownSelectedLines,
|
|
getMarkdownLineIndexAtOffset,
|
|
getMarkdownLineStartOffset,
|
|
getMarkdownSelectionState,
|
|
insertMarkdownBlockSnippet,
|
|
insertMarkdownInlineLink,
|
|
replaceMarkdownSelection,
|
|
transformMarkdownSelectedLines,
|
|
wrapMarkdownInlineSelection
|
|
} from '../../lib/markdown-source-selection.js'
|
|
import {
|
|
getEditorHistoryKeyboardAction,
|
|
isEditorModeToggleShortcut,
|
|
isMediaPickerCloseShortcut
|
|
} from '../../lib/markdown-editor-keyboard.js'
|
|
import {
|
|
isCodeFenceBodyLine,
|
|
resolvePreviewFocusPosition as resolveMarkdownPreviewFocusPosition
|
|
} from '../../lib/markdown-editor-focus.js'
|
|
import {
|
|
createSelectedMediaInsertPlan,
|
|
filterImageUploadFiles,
|
|
resolveImageUploadInsertTarget,
|
|
resolveUploadFetchErrorMessage
|
|
} from '../../lib/markdown-editor-media.js'
|
|
import {
|
|
createCalloutOptionsReplacementPlan,
|
|
createCodeOptionsReplacementPlan,
|
|
createEmbedUrlReplacementPlan,
|
|
createGalleryImageMovePlan,
|
|
createGalleryImagesAppendPlan,
|
|
createLiveInlineColorValue,
|
|
createMediaImageRemovePlan,
|
|
createMediaImagesReplacementPlan,
|
|
createMediaImageUpdatePlan,
|
|
createMediaUseAltPatch,
|
|
createQuoteBackgroundValue,
|
|
createSourceInlineColorValue,
|
|
createToggleOptionsReplacementPlan
|
|
} from '../../lib/markdown-editor-block-options.js'
|
|
import {
|
|
createPreviewAppendParagraphValue,
|
|
createPreviewDeleteLineValue,
|
|
createPreviewExtractGalleryImageValue,
|
|
createPreviewGalleryReorderValue,
|
|
createPreviewImagesMergeValue,
|
|
createPreviewInsertAfterLineResult,
|
|
createPreviewInsertImageToGalleryValue,
|
|
createPreviewMergeWithPreviousLineValue,
|
|
createPreviewRemoveGalleryImageResult,
|
|
parseEditorImageLine
|
|
} from '../../lib/markdown-editor-preview-blocks.js'
|
|
import {
|
|
createSlashCommandApplyPlan,
|
|
createSlashCommandEditorEffectPlan,
|
|
getNextSlashHighlightedIndex,
|
|
getSlashCommandFocusTarget,
|
|
getSlashCommandTextareaSelection,
|
|
getSlashMenuKeyboardAction,
|
|
insertMarkdownLinesAtSlashTarget,
|
|
parseSlashInput,
|
|
resolveSlashCommand,
|
|
shouldCaptureSlashMenuKeyboardAction,
|
|
shouldIgnorePendingMediaSlashCommit
|
|
} from '../../lib/markdown-slash-commands.js'
|
|
import {
|
|
calculateSlashMenuPosition,
|
|
DEFAULT_SLASH_MENU_HEIGHT,
|
|
DEFAULT_SLASH_MENU_WIDTH
|
|
} from '../../lib/markdown-slash-menu-position.js'
|
|
import { calculateSlashMenuScrollTop } from '../../lib/markdown-slash-menu-scroll.js'
|
|
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
|
import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSlashMenu.js'
|
|
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
|
|
import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js'
|
|
import { useAdminMarkdownFocusBridge } from '../../composables/useAdminMarkdownFocusBridge.js'
|
|
import { useAdminMarkdownPreviewFocus } from '../../composables/useAdminMarkdownPreviewFocus.js'
|
|
import { useAdminMarkdownTextareaFocus } from '../../composables/useAdminMarkdownTextareaFocus.js'
|
|
import { useAdminMarkdownBlockPanel } from '../../composables/useAdminMarkdownBlockPanel.js'
|
|
import {
|
|
buildDefaultUploadSizeLimits,
|
|
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
|
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
|
|
normalizeUploadMaxFileCount,
|
|
normalizeUploadMaxTotalSize
|
|
} from '../../lib/upload-size-limit.js'
|
|
import AdminMarkdownModeToggle from './AdminMarkdownModeToggle.vue'
|
|
import AdminMarkdownPreviewSurface from './AdminMarkdownPreviewSurface.vue'
|
|
import AdminMarkdownSlashMenu from './AdminMarkdownSlashMenu.vue'
|
|
import AdminMarkdownWriteSurface from './AdminMarkdownWriteSurface.vue'
|
|
|
|
const { toast, showToast } = useAdminToast()
|
|
const runtimeConfig = useRuntimeConfig()
|
|
const { data: uploadSettings } = useFetch('/admin/api/settings', {
|
|
key: 'admin-markdown-editor-upload-settings'
|
|
})
|
|
const uploadSizeLimits = computed(() => buildDefaultUploadSizeLimits({
|
|
image: Number(runtimeConfig.public.maxFileSize || 10485760),
|
|
video: Number(runtimeConfig.public.maxVideoFileSize || 209715200),
|
|
audio: Number(runtimeConfig.public.maxAudioFileSize || 52428800),
|
|
document: Number(runtimeConfig.public.maxDocumentFileSize || 52428800)
|
|
}))
|
|
const uploadMaxFileCount = computed(() => normalizeUploadMaxFileCount(uploadSettings.value?.uploadMaxFileCount || DEFAULT_UPLOAD_MAX_FILE_COUNT))
|
|
const uploadMaxTotalSize = computed(() => normalizeUploadMaxTotalSize(uploadSettings.value?.uploadMaxTotalSize || DEFAULT_UPLOAD_MAX_TOTAL_SIZE))
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: [String, Array, Object],
|
|
default: ''
|
|
},
|
|
/** 작성/미리보기 토글 버튼 Teleport 대상(AdminPostForm 헤더) */
|
|
modeToggleTeleportTo: {
|
|
type: String,
|
|
default: ''
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'block-panel'])
|
|
const activeMode = defineModel('editorMode', {
|
|
type: String,
|
|
default: 'write'
|
|
})
|
|
|
|
const editorRootRef = ref(null)
|
|
const writeSurfaceRef = ref(null)
|
|
const previewSurfaceRef = ref(null)
|
|
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
|
|
const activeLogicalLineIndex = ref(0)
|
|
/** 소스 모드 입력 이벤트 직후 textarea의 최신 본문 값 */
|
|
const writeModeContextValue = ref(null)
|
|
/** textarea 자동 줄바꿈을 반영한 논리 줄별 표시 높이(px) */
|
|
const gutterLineHeights = ref([])
|
|
const lastSelectionState = ref({
|
|
start: 0,
|
|
end: 0
|
|
})
|
|
|
|
const {
|
|
liveSlashSourceLine,
|
|
liveSlashQuery,
|
|
liveSlashHighlightedIndex,
|
|
liveSlashMenuPosition,
|
|
liveSlashMenuRef,
|
|
liveSlashInsertLine,
|
|
liveSlashKeyboardNav,
|
|
liveSlashVisible,
|
|
liveSlashSuppressedLineList,
|
|
visibleLiveSlashCommands,
|
|
clearLiveSlashState,
|
|
openLiveSlashMenu,
|
|
isLiveSlashLineSuppressed,
|
|
suppressLiveSlashLine,
|
|
releaseLiveSlashLine,
|
|
resetLiveSlashHighlightedIndex,
|
|
clampLiveSlashHighlightedIndex,
|
|
startLiveSlashKeyboardNavigation,
|
|
stopLiveSlashKeyboardNavigation
|
|
} = useAdminMarkdownSlashMenu()
|
|
const {
|
|
mediaItems,
|
|
isMediaPickerOpen,
|
|
isLoadingMedia,
|
|
isUploading,
|
|
mediaPickerTarget,
|
|
activeMediaPickerTab,
|
|
mediaSearchQuery,
|
|
mediaSearchInput,
|
|
onMediaSearchInput,
|
|
onMediaSearchCompositionEnd,
|
|
mediaPickerPage,
|
|
selectedMediaUrls,
|
|
mediaPickerAccept,
|
|
mediaPickerTitle,
|
|
isGalleryMediaPicker,
|
|
mediaPickerPageCount,
|
|
filteredMediaItems,
|
|
mediaPickerRangeLabel,
|
|
mediaPickerPageItems,
|
|
mediaPickerUploadHint,
|
|
mediaPickerEmptyMessage,
|
|
mediaPickerApplyState,
|
|
isMediaItemSelectableForCurrentTarget,
|
|
isUploadFileAllowedForPicker,
|
|
openMediaPickerState,
|
|
closeMediaPickerState,
|
|
fetchMediaItems,
|
|
uploadMediaFilesWithState,
|
|
uploadFilesIntoMediaPicker,
|
|
setMediaPickerPage: setMediaPickerPageState,
|
|
toggleMediaSelection,
|
|
resolveCurrentSelectedMediaItems,
|
|
getGallerySelectionOrder
|
|
} = useAdminMarkdownMediaPicker({
|
|
uploadSizeLimits,
|
|
uploadMaxFileCount,
|
|
uploadMaxTotalSize
|
|
})
|
|
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 미리보기 스크롤 영역 */
|
|
let liveSlashPreviewScrollElement = null
|
|
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */
|
|
let liveSlashTextareaScrollElement = null
|
|
|
|
/**
|
|
* 작성 표면의 textarea DOM을 반환한다.
|
|
* @returns {HTMLTextAreaElement|null} textarea DOM
|
|
*/
|
|
const getWriteTextareaElement = () => writeSurfaceRef.value?.getTextareaElement?.() ?? null
|
|
|
|
const {
|
|
focusPreviewEditableAtLine,
|
|
getCurrentPreviewSourceLine,
|
|
getPreviewSourceLineElement,
|
|
getPreviewSurfaceElement,
|
|
scrollPreviewToTop,
|
|
selectAllPreviewDocument
|
|
} = useAdminMarkdownPreviewFocus({
|
|
previewSurfaceRef
|
|
})
|
|
|
|
const markdownValue = computed({
|
|
get: () => normalizeMarkdownContent(props.modelValue),
|
|
set: (value) => emit('update:modelValue', value)
|
|
})
|
|
|
|
watch(markdownValue, () => {
|
|
const textarea = getWriteTextareaElement()
|
|
|
|
if (activeMode.value !== 'write'
|
|
|| !textarea
|
|
|| (import.meta.client && document.activeElement !== textarea)) {
|
|
writeModeContextValue.value = null
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 오프셋이 속한 마크다운 줄 번호를 반환한다.
|
|
* @param {string} value - 마크다운 본문
|
|
* @param {number} offset - 문자 오프셋
|
|
* @returns {number} 줄 번호
|
|
*/
|
|
const getLineIndexFromOffset = (value, offset) => {
|
|
const safeOffset = Math.max(0, Math.min(String(value || '').length, offset))
|
|
return String(value || '').slice(0, safeOffset).split('\n').length - 1
|
|
}
|
|
|
|
/**
|
|
* 현재 줄이 인라인 색상 적용 대상인지 확인한다.
|
|
* @param {number} lineIndex - 줄 번호
|
|
* @returns {boolean} 적용 가능 여부
|
|
*/
|
|
const canApplyInlineColorAtLine = (lineIndex) => {
|
|
const context = resolveActiveBlockContext(markdownValue.value, lineIndex)
|
|
return context?.kind !== 'code'
|
|
}
|
|
|
|
/**
|
|
* 소스 모드 textarea 선택 범위를 색상 패널 컨텍스트로 변환한다.
|
|
* @returns {{ kind: 'inline-color', mode: 'source', start: number, end: number, text: string }|null} 선택 컨텍스트
|
|
*/
|
|
const getSourceInlineColorSelectionContext = () => {
|
|
const textarea = getWriteTextareaElement()
|
|
|
|
if (activeMode.value !== 'write' || !textarea || document.activeElement !== textarea) {
|
|
return null
|
|
}
|
|
|
|
const start = Math.min(textarea.selectionStart, textarea.selectionEnd)
|
|
const end = Math.max(textarea.selectionStart, textarea.selectionEnd)
|
|
|
|
if (start === end) {
|
|
return null
|
|
}
|
|
|
|
const value = markdownValue.value || ''
|
|
const selectedText = value.slice(start, end)
|
|
|
|
if (!selectedText.trim() || selectedText.includes('\n')) {
|
|
return null
|
|
}
|
|
|
|
const startLine = getLineIndexFromOffset(value, start)
|
|
const endLine = getLineIndexFromOffset(value, end)
|
|
|
|
if (startLine !== endLine || !canApplyInlineColorAtLine(startLine)) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
kind: 'inline-color',
|
|
mode: 'source',
|
|
start,
|
|
end,
|
|
text: selectedText
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DOM 노드가 속한 라이브 편집 요소를 반환한다.
|
|
* @param {Node|null} node - 선택 노드
|
|
* @returns {HTMLElement|null} 편집 요소
|
|
*/
|
|
const getEditableElementFromSelectionNode = (node) => {
|
|
if (!node) {
|
|
return null
|
|
}
|
|
|
|
const element = node instanceof Element ? node : node.parentElement
|
|
const editable = element?.closest?.('[contenteditable="true"][data-source-line]')
|
|
return editable instanceof HTMLElement ? editable : null
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드 선택 범위를 색상 패널 컨텍스트로 변환한다.
|
|
* @returns {{ kind: 'inline-color', mode: 'live', sourceLine: number, startOffset: number, endOffset: number, bodyText: string, text: string }|null} 선택 컨텍스트
|
|
*/
|
|
const getLiveInlineColorSelectionContext = () => {
|
|
if (activeMode.value !== 'preview' || !import.meta.client) {
|
|
return null
|
|
}
|
|
|
|
const selection = window.getSelection()
|
|
|
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
return null
|
|
}
|
|
|
|
const range = selection.getRangeAt(0)
|
|
const startEditable = getEditableElementFromSelectionNode(range.startContainer)
|
|
const endEditable = getEditableElementFromSelectionNode(range.endContainer)
|
|
|
|
if (!startEditable || startEditable !== endEditable) {
|
|
return null
|
|
}
|
|
|
|
const sourceLine = Number(startEditable.getAttribute('data-source-line'))
|
|
const sourceLineEnd = Number(startEditable.getAttribute('data-source-line-end') || sourceLine)
|
|
|
|
if (!Number.isInteger(sourceLine) || sourceLine !== sourceLineEnd || !canApplyInlineColorAtLine(sourceLine)) {
|
|
return null
|
|
}
|
|
|
|
const bodyText = readEditableTextFromElement(startEditable, { trimEnd: false })
|
|
const startOffset = getSelectionOffsetInElement(startEditable, range, 'start')
|
|
const endOffset = getSelectionOffsetInElement(startEditable, range, 'end')
|
|
const safeStart = Math.max(0, Math.min(startOffset, endOffset))
|
|
const safeEnd = Math.min(bodyText.length, Math.max(startOffset, endOffset))
|
|
const selectedText = bodyText.slice(safeStart, safeEnd)
|
|
|
|
if (!selectedText.trim()) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
kind: 'inline-color',
|
|
mode: 'live',
|
|
sourceLine,
|
|
startOffset: safeStart,
|
|
endOffset: safeEnd,
|
|
bodyText,
|
|
text: selectedText
|
|
}
|
|
}
|
|
|
|
const {
|
|
isBlockPanelEngaged,
|
|
inlineColorSelectionContext,
|
|
activeBlockContext,
|
|
activeMediaBlock,
|
|
activePanelContext,
|
|
isBlockPanelVisible,
|
|
ensureBlockPanelEngaged,
|
|
closeBlockPanel,
|
|
syncBlockPanelState,
|
|
syncInlineColorSelectionContext,
|
|
handleTextareaFocusIn,
|
|
handleTextareaFocusOut,
|
|
handleBlockPanelFocusIn,
|
|
handleBlockPanelFocusOut,
|
|
handleTextareaCompositionStart,
|
|
handleTextareaCompositionEnd: markTextareaCompositionEnd,
|
|
handleLiveLineFocus,
|
|
handleLiveLineBlur,
|
|
resetBlockPanelContext,
|
|
clearBlockPanelFocusTimer
|
|
} = useAdminMarkdownBlockPanel({
|
|
activeMode,
|
|
value: markdownValue,
|
|
writeModeContextValue,
|
|
activeLogicalLineIndex,
|
|
isMediaPickerOpen,
|
|
resolveBlockContext: resolveActiveBlockContext,
|
|
getInlineColorSelectionContext: () => getSourceInlineColorSelectionContext() || getLiveInlineColorSelectionContext(),
|
|
emitBlockPanel: (payload) => emit('block-panel', payload)
|
|
})
|
|
|
|
/**
|
|
* 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
|
|
* @returns {number}
|
|
*/
|
|
const gutterLineCount = computed(() => {
|
|
const raw = markdownValue.value ?? ''
|
|
const n = raw.split('\n').length
|
|
|
|
return Math.max(1, n)
|
|
})
|
|
|
|
/**
|
|
* 소스 textarea의 줄 시작 오프셋을 반환한다.
|
|
* @param {number} lineIndex - 줄 번호(0-based)
|
|
* @returns {number} 문자 오프셋
|
|
*/
|
|
const getLineStartOffset = (lineIndex) => {
|
|
return getMarkdownLineStartOffset(markdownValue.value, lineIndex)
|
|
}
|
|
|
|
/**
|
|
* 문자 오프셋이 속한 소스 줄 번호를 반환한다.
|
|
* @param {number} offset - 문자 오프셋
|
|
* @returns {number} 줄 번호(0-based)
|
|
*/
|
|
const getLineIndexAtOffset = (offset) => {
|
|
return getMarkdownLineIndexAtOffset(markdownValue.value, offset)
|
|
}
|
|
|
|
const {
|
|
getGutterLineHeight,
|
|
getNearestScrollContainer,
|
|
syncGutterLineHeights,
|
|
syncTextareaHeight,
|
|
refreshCaretLogicalLine,
|
|
rememberTextareaSelection,
|
|
focusTextareaAtLine,
|
|
restoreTextareaFocus
|
|
} = useAdminMarkdownTextareaFocus({
|
|
value: markdownValue,
|
|
activeMode,
|
|
writeModeContextValue,
|
|
lastSelectionState,
|
|
activeLogicalLineIndex,
|
|
gutterLineHeights,
|
|
editorRootRef,
|
|
minTextareaHeight: 620,
|
|
getWriteTextareaElement,
|
|
getLineStartOffset,
|
|
syncBlockPanelState,
|
|
syncWriteSlashState: () => syncWriteSlashState()
|
|
})
|
|
|
|
/**
|
|
* 현재 선택 위치에 맞춰 에디터 포커스를 복원한다.
|
|
* @param {{ 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
|
|
|
|
focusPreviewEditableAtLine(focusLine, 0, 'auto', offset, 'center')
|
|
})
|
|
}
|
|
|
|
const {
|
|
recordEditorHistory,
|
|
restoreEditorHistory
|
|
} = useAdminMarkdownHistory({
|
|
value: markdownValue,
|
|
lastSelectionState,
|
|
restoreFocusAfterHistory: restoreEditorFocusAfterHistory
|
|
})
|
|
|
|
/**
|
|
* 라이브 모드에 실제로 존재하는 편집 줄로 포커스 대상을 보정한다.
|
|
* @param {number} line - 원본 줄 번호
|
|
* @param {number} offset - 원본 줄 내부 오프셋
|
|
* @returns {{ line: number, offset: number }} 라이브 포커스 대상
|
|
*/
|
|
const resolvePreviewFocusPosition = (line, offset) => {
|
|
return resolveMarkdownPreviewFocusPosition({
|
|
value: markdownValue.value,
|
|
line,
|
|
offset
|
|
})
|
|
}
|
|
|
|
/**
|
|
* textarea 포커스 진입
|
|
* @returns {void}
|
|
*/
|
|
const onTextareaFocus = () => {
|
|
handleTextareaFocusIn()
|
|
refreshCaretLogicalLine()
|
|
}
|
|
|
|
/**
|
|
* textarea 포커스 이탈(블록 패널으로 이동 시 유지)
|
|
* @returns {void}
|
|
*/
|
|
const onTextareaBlur = () => {
|
|
handleTextareaFocusOut()
|
|
}
|
|
|
|
/**
|
|
* IME 조합 입력 종료 후 실제 커서 줄 기준으로 패널을 다시 동기화한다.
|
|
* @returns {void}
|
|
*/
|
|
const handleTextareaCompositionEnd = () => {
|
|
markTextareaCompositionEnd()
|
|
refreshCaretLogicalLine()
|
|
nextTick(syncBlockPanelState)
|
|
}
|
|
|
|
/**
|
|
* 소스(작성) 모드 textarea 현재 줄의 슬래시 입력 상태를 갱신한다.
|
|
* @returns {void}
|
|
*/
|
|
const syncWriteSlashState = () => {
|
|
if (activeMode.value !== 'write') {
|
|
return
|
|
}
|
|
|
|
const line = activeLogicalLineIndex.value
|
|
const lines = (writeModeContextValue.value ?? markdownValue.value ?? '').split('\n')
|
|
const lineValue = lines[line] ?? ''
|
|
|
|
if (isLiveSlashLineSuppressed(line, lineValue)) {
|
|
if (liveSlashSourceLine.value !== null) {
|
|
clearLiveSlashState()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
releaseLiveSlashLine(line)
|
|
|
|
if (isCodeFenceBodyLine(lines, line)) {
|
|
onLiveSlashEnd({ sourceLine: line })
|
|
return
|
|
}
|
|
|
|
const parsed = parseSlashInput(lines[line] ?? '')
|
|
|
|
if (!parsed) {
|
|
onLiveSlashEnd({ sourceLine: line })
|
|
return
|
|
}
|
|
|
|
openLiveSlashMenu(line, parsed.query)
|
|
nextTick(updateLiveSlashMenuPosition)
|
|
}
|
|
|
|
watch(() => props.modelValue, () => {
|
|
if (isBlockPanelEngaged.value) {
|
|
syncBlockPanelState()
|
|
return
|
|
}
|
|
|
|
refreshCaretLogicalLine()
|
|
})
|
|
|
|
watch(markdownValue, (nextValue, previousValue) => {
|
|
if (nextValue === previousValue) {
|
|
return
|
|
}
|
|
|
|
recordEditorHistory(previousValue)
|
|
})
|
|
|
|
const {
|
|
rememberWritePositionForPreview,
|
|
rememberPreviewPosition,
|
|
requestPreviewFocus
|
|
} = useAdminMarkdownFocusBridge({
|
|
activeMode,
|
|
value: markdownValue,
|
|
lastSelectionState,
|
|
activeLogicalLineIndex,
|
|
getWriteTextareaElement,
|
|
getLineIndexAtOffset,
|
|
getLineStartOffset,
|
|
resolvePreviewFocusPosition,
|
|
getCurrentPreviewSourceLine,
|
|
syncTextareaHeight,
|
|
focusTextareaAtLine,
|
|
restoreTextareaFocus,
|
|
scrollPreviewToTop,
|
|
focusPreviewEditableAtLine
|
|
})
|
|
|
|
/**
|
|
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
|
|
* @returns {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 = getWriteTextareaElement()
|
|
|
|
if (!textarea || document.activeElement !== textarea) {
|
|
return
|
|
}
|
|
|
|
refreshCaretLogicalLine()
|
|
}
|
|
|
|
/**
|
|
* 에디터 안에서 Cmd/Ctrl+E를 누르면 작성/미리보기 모드를 전환한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onDocumentKeydown = (event) => {
|
|
if (isMediaPickerCloseShortcut(event) && isMediaPickerOpen.value) {
|
|
event.preventDefault()
|
|
closeMediaPicker()
|
|
return
|
|
}
|
|
|
|
const root = editorRootRef.value
|
|
|
|
if (!root || !root.contains(document.activeElement)) {
|
|
return
|
|
}
|
|
|
|
const historyKeyboardAction = getEditorHistoryKeyboardAction(event)
|
|
|
|
if (historyKeyboardAction) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
restoreEditorHistory(historyKeyboardAction)
|
|
return
|
|
}
|
|
|
|
if (liveSlashVisible.value) {
|
|
const slashKeyboardAction = getSlashMenuKeyboardAction(event.key)
|
|
const slashCommandCount = visibleLiveSlashCommands.value.length
|
|
|
|
if (slashKeyboardAction === 'navigate') {
|
|
if (!shouldCaptureSlashMenuKeyboardAction({
|
|
action: slashKeyboardAction,
|
|
commandCount: slashCommandCount
|
|
})) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
startLiveSlashKeyboardNavigation()
|
|
|
|
liveSlashHighlightedIndex.value = getNextSlashHighlightedIndex({
|
|
currentIndex: liveSlashHighlightedIndex.value,
|
|
commandCount: slashCommandCount,
|
|
key: event.key
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
if (slashKeyboardAction === 'apply') {
|
|
const command = visibleLiveSlashCommands.value[liveSlashHighlightedIndex.value]
|
|
|
|
if (!shouldCaptureSlashMenuKeyboardAction({
|
|
action: slashKeyboardAction,
|
|
commandCount: slashCommandCount
|
|
}) || !command) {
|
|
clearLiveSlashState()
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
applyLiveSlashCommand(command)
|
|
return
|
|
}
|
|
|
|
if (slashKeyboardAction === 'cancel') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
cancelLiveSlashCommand()
|
|
return
|
|
}
|
|
}
|
|
|
|
if (!isEditorModeToggleShortcut(event)) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
toggleEditorMode()
|
|
}
|
|
|
|
/**
|
|
* 블록 패널 바깥 클릭 시 패널을 닫는다.
|
|
* @param {PointerEvent} event - 포인터 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onDocumentPointerDown = (event) => {
|
|
if (!isBlockPanelVisible.value || isMediaPickerOpen.value) {
|
|
return
|
|
}
|
|
|
|
const target = event.target
|
|
|
|
if (!(target instanceof Element)) {
|
|
return
|
|
}
|
|
|
|
if (target.closest('.admin-editor-block-panel')
|
|
|| target.closest('.admin-markdown-editor')
|
|
|| target.closest('.admin-markdown-editor__media-modal')) {
|
|
return
|
|
}
|
|
|
|
closeBlockPanel()
|
|
}
|
|
|
|
document.addEventListener('selectionchange', onSelectionChange)
|
|
document.addEventListener('keydown', onDocumentKeydown, true)
|
|
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
|
window.addEventListener('resize', syncGutterLineHeights)
|
|
|
|
onBeforeUnmount(() => {
|
|
clearBlockPanelFocusTimer()
|
|
stopLiveSlashKeyboardNavigation()
|
|
removeLiveSlashRepositionListeners()
|
|
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(() => {
|
|
focusPreviewEditableAtLine(focusLine, 0, 'start')
|
|
})
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
nextTick(() => {
|
|
const textarea = getWriteTextareaElement()
|
|
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 = () => {
|
|
return getMarkdownSelectionState({
|
|
value: markdownValue.value,
|
|
textarea: getWriteTextareaElement()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* textarea 커서 위치를 갱신한다.
|
|
* @param {number} start - 시작 위치
|
|
* @param {number} end - 끝 위치
|
|
* @param {{ preserveScroll?: boolean }} options - 선택 옵션
|
|
* @returns {void}
|
|
*/
|
|
const setTextareaSelection = (start, end = start, options = {}) => {
|
|
const scrollContainer = options.preserveScroll && import.meta.client
|
|
? getNearestScrollContainer(editorRootRef.value)
|
|
: null
|
|
const previousScrollState = options.preserveScroll
|
|
? {
|
|
textareaTop: getWriteTextareaElement()?.scrollTop,
|
|
containerTop: scrollContainer?.scrollTop,
|
|
windowX: import.meta.client ? window.scrollX : 0,
|
|
windowY: import.meta.client ? window.scrollY : 0
|
|
}
|
|
: null
|
|
|
|
/**
|
|
* textarea와 에디터 스크롤 위치를 복원한다.
|
|
* @param {HTMLTextAreaElement} textarea - 대상 textarea
|
|
* @returns {void}
|
|
*/
|
|
const restoreScroll = (textarea) => {
|
|
if (!previousScrollState) {
|
|
return
|
|
}
|
|
|
|
if (Number.isFinite(previousScrollState.textareaTop)) {
|
|
textarea.scrollTop = previousScrollState.textareaTop
|
|
}
|
|
|
|
if (scrollContainer && Number.isFinite(previousScrollState.containerTop)) {
|
|
scrollContainer.scrollTop = previousScrollState.containerTop
|
|
}
|
|
|
|
if (import.meta.client && Number.isFinite(previousScrollState.windowY)) {
|
|
window.scrollTo(previousScrollState.windowX, previousScrollState.windowY)
|
|
}
|
|
}
|
|
|
|
nextTick(() => {
|
|
const textarea = getWriteTextareaElement()
|
|
if (!textarea) {
|
|
return
|
|
}
|
|
|
|
textarea.focus()
|
|
textarea.setSelectionRange(start, end)
|
|
restoreScroll(textarea)
|
|
|
|
if (import.meta.client) {
|
|
window.requestAnimationFrame(() => restoreScroll(textarea))
|
|
}
|
|
|
|
refreshCaretLogicalLine()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 선택 영역을 지정 문자열로 교체한다.
|
|
* @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()
|
|
const result = replaceMarkdownSelection({
|
|
value,
|
|
start,
|
|
end,
|
|
replacement,
|
|
cursorOffset,
|
|
selectionLength
|
|
})
|
|
|
|
markdownValue.value = result.value
|
|
setTextareaSelection(result.selectionStart, result.selectionEnd)
|
|
}
|
|
|
|
/**
|
|
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
|
|
* @param {string} snippet - 삽입할 마크다운
|
|
* @returns {void}
|
|
*/
|
|
const insertBlockSnippet = (snippet) => {
|
|
const { start, end, value } = getSelectionState()
|
|
const result = insertMarkdownBlockSnippet({
|
|
value,
|
|
start,
|
|
end,
|
|
snippet
|
|
})
|
|
|
|
markdownValue.value = result.value
|
|
setTextareaSelection(result.selectionStart, result.selectionEnd)
|
|
}
|
|
|
|
/**
|
|
* 선택 텍스트를 인라인 마크다운으로 감싼다.
|
|
* @param {string} prefix - 앞 표식
|
|
* @param {string} suffix - 뒤 표식
|
|
* @param {string} placeholder - 선택이 없을 때 들어갈 텍스트
|
|
* @returns {void}
|
|
*/
|
|
const wrapInline = (prefix, suffix, placeholder) => {
|
|
const { start, end, value } = getSelectionState()
|
|
const result = wrapMarkdownInlineSelection({
|
|
value,
|
|
start,
|
|
end,
|
|
prefix,
|
|
suffix,
|
|
placeholder
|
|
})
|
|
|
|
markdownValue.value = result.value
|
|
setTextareaSelection(result.selectionStart, result.selectionEnd)
|
|
}
|
|
|
|
/**
|
|
* 선택 텍스트를 링크 마크다운으로 바꾼다.
|
|
* @returns {void}
|
|
*/
|
|
const insertInlineLink = () => {
|
|
const { start, end, value } = getSelectionState()
|
|
const result = insertMarkdownInlineLink({
|
|
value,
|
|
start,
|
|
end
|
|
})
|
|
|
|
markdownValue.value = result.value
|
|
setTextareaSelection(result.selectionStart, result.selectionEnd)
|
|
}
|
|
|
|
/**
|
|
* 선택된 줄을 변환한다.
|
|
* @param {(line: string) => string} transformLine - 줄 변환 함수
|
|
* @returns {void}
|
|
*/
|
|
const transformSelectedLines = (transformLine) => {
|
|
const { start, end, value } = getSelectionState()
|
|
const result = transformMarkdownSelectedLines({
|
|
value,
|
|
start,
|
|
end,
|
|
transformLine
|
|
})
|
|
|
|
markdownValue.value = result.value
|
|
setTextareaSelection(result.selectionStart, result.selectionEnd)
|
|
}
|
|
|
|
/**
|
|
* 소스 모드에서 현재 선택 범위가 걸친 줄을 삭제한다.
|
|
* @returns {void}
|
|
*/
|
|
const deleteSelectedSourceLines = () => {
|
|
const { start, end, value } = getSelectionState()
|
|
const result = deleteMarkdownSelectedLines({
|
|
value,
|
|
start,
|
|
end
|
|
})
|
|
|
|
markdownValue.value = result.value
|
|
setTextareaSelection(result.selectionStart, result.selectionEnd)
|
|
}
|
|
|
|
/**
|
|
* 제목 마크다운을 적용한다.
|
|
* @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 {Event} event - 이미지 오류 이벤트
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {void}
|
|
*/
|
|
const onMediaPickerImageError = (event, item) => {
|
|
const image = event.target
|
|
|
|
if (!(image instanceof HTMLImageElement) || image.dataset.fallbackApplied === 'true') {
|
|
return
|
|
}
|
|
|
|
image.dataset.fallbackApplied = 'true'
|
|
image.src = String(item?.url || '')
|
|
}
|
|
|
|
/**
|
|
* 지정 줄 범위를 새 줄 목록으로 교체한다.
|
|
* @param {number} startLine - 시작 줄
|
|
* @param {number} endLine - 끝 줄
|
|
* @param {string[]} replacementLines - 대체 줄 목록
|
|
* @param {boolean} focusEditor - 교체 후 textarea에 포커스를 돌릴지 여부
|
|
* @returns {void}
|
|
*/
|
|
const replaceLineRange = (startLine, endLine, replacementLines, focusEditor = true) => {
|
|
const lines = (markdownValue.value || '').split('\n')
|
|
const nextLines = [
|
|
...lines.slice(0, startLine),
|
|
...replacementLines,
|
|
...lines.slice(endLine + 1)
|
|
]
|
|
|
|
markdownValue.value = nextLines.join('\n')
|
|
|
|
if (focusEditor) {
|
|
setTextareaSelection(nextLines.slice(0, startLine + replacementLines.length).join('\n').length)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 줄 범위 교체 계획을 현재 마크다운 본문에 적용한다.
|
|
* @param {{ startLine: number, endLine: number, replacementLines: string[] }|null} plan - 교체 계획
|
|
* @param {boolean} focusEditor - 교체 후 textarea에 포커스를 돌릴지 여부
|
|
* @returns {boolean} 적용 여부
|
|
*/
|
|
const applyLineReplacementPlan = (plan, focusEditor = false) => {
|
|
if (!plan) {
|
|
return false
|
|
}
|
|
|
|
replaceLineRange(plan.startLine, plan.endLine, plan.replacementLines, focusEditor)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* 현재 문서 기준 fenced 블록의 닫는 줄을 찾는다.
|
|
* @param {number} startLine - 시작 줄
|
|
* @param {number} fallbackEndLine - 기본 끝 줄
|
|
* @returns {number} 보정된 끝 줄
|
|
*/
|
|
const resolveCurrentFencedBlockEndLine = (startLine, fallbackEndLine) => {
|
|
const lines = (markdownValue.value || '').split('\n')
|
|
const opener = lines[startLine]?.trim() ?? ''
|
|
|
|
if (opener.startsWith(':::')) {
|
|
const closingLine = lines.findIndex((line, index) => index > startLine && line.trim() === ':::')
|
|
return closingLine >= 0 ? closingLine : fallbackEndLine
|
|
}
|
|
|
|
if (opener.startsWith('```')) {
|
|
const closingLine = lines.findIndex((line, index) => index > startLine && line.trim().startsWith('```'))
|
|
return closingLine >= 0 ? closingLine : fallbackEndLine
|
|
}
|
|
|
|
return fallbackEndLine
|
|
}
|
|
|
|
/**
|
|
* 현재 미디어 블록을 이미지 목록 기준으로 다시 작성한다.
|
|
* @param {Array<{ alt: string, url: string, width?: string }>} images - 이미지 목록
|
|
* @returns {void}
|
|
*/
|
|
const replaceActiveMediaImages = (images) => {
|
|
applyLineReplacementPlan(
|
|
createMediaImagesReplacementPlan(activeMediaBlock.value, images),
|
|
false
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 현재 미디어 블록의 특정 이미지를 수정한다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Partial<{ alt: string, url: string, width: string, pixelWidth: number|null }>} patch - 변경 값
|
|
* @returns {void}
|
|
*/
|
|
const updateActiveMediaImage = (imageIndex, patch) => {
|
|
const plan = createMediaImageUpdatePlan(activeMediaBlock.value, imageIndex, patch)
|
|
|
|
if (!plan) {
|
|
return
|
|
}
|
|
|
|
ensureBlockPanelEngaged()
|
|
applyLineReplacementPlan(plan, false)
|
|
}
|
|
|
|
/**
|
|
* 현재 미디어 이미지의 파일명 캡션 사용 여부를 바꾼다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {boolean} enabled - 파일명 캡션 사용 여부
|
|
* @returns {void}
|
|
*/
|
|
const setActiveMediaUseAlt = (imageIndex, enabled) => {
|
|
const image = activeMediaBlock.value?.images[imageIndex]
|
|
const patch = createMediaUseAltPatch(image, enabled)
|
|
|
|
if (!patch) {
|
|
return
|
|
}
|
|
|
|
updateActiveMediaImage(imageIndex, patch)
|
|
}
|
|
|
|
/**
|
|
* 미리보기에서 갤러리 순서 변경
|
|
* @param {{ startLine: number, endLine: number, images: Array<Object> }} payload - 갤러리 줄 범위·이미지 목록
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewGalleryReorder = ({ startLine, endLine, images }) => {
|
|
const nextValue = createPreviewGalleryReorderValue({
|
|
value: markdownValue.value,
|
|
startLine,
|
|
endLine,
|
|
images
|
|
})
|
|
|
|
if (nextValue !== null) {
|
|
markdownValue.value = nextValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드에서 두 이미지 블록을 갤러리 fenced block으로 병합한다.
|
|
* @param {{ sourceLine: number, targetLine: number }} payload - 원본·대상 줄(0-based)
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewMergeImagesToGallery = ({ sourceLine, targetLine }) => {
|
|
const nextValue = createPreviewImagesMergeValue({
|
|
value: markdownValue.value,
|
|
sourceLine,
|
|
targetLine
|
|
})
|
|
|
|
if (nextValue !== null) {
|
|
markdownValue.value = nextValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드에서 단일 이미지 블록을 기존 갤러리에 추가한다.
|
|
* @param {{ sourceLine: number, startLine: number, endLine: number, targetIndex: number, image?: Object }} payload - 이미지 줄·갤러리 범위·삽입 위치
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewInsertImageToGallery = ({
|
|
sourceLine,
|
|
startLine,
|
|
endLine,
|
|
targetIndex,
|
|
image
|
|
}) => {
|
|
const nextValue = createPreviewInsertImageToGalleryValue({
|
|
value: markdownValue.value,
|
|
sourceLine,
|
|
startLine,
|
|
endLine,
|
|
targetIndex,
|
|
image
|
|
})
|
|
|
|
if (nextValue !== null) {
|
|
markdownValue.value = nextValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드 갤러리에서 특정 이미지를 삭제한다.
|
|
* @param {{ startLine: number, endLine: number, imageIndex: number }} payload - 갤러리 범위·이미지 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewRemoveGalleryImage = ({ startLine, endLine, imageIndex }) => {
|
|
const result = createPreviewRemoveGalleryImageResult({
|
|
value: markdownValue.value,
|
|
startLine,
|
|
endLine,
|
|
imageIndex
|
|
})
|
|
|
|
if (!result) {
|
|
return
|
|
}
|
|
|
|
markdownValue.value = result.value
|
|
activeLogicalLineIndex.value = result.focusLine
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드에서 갤러리 이미지를 블록 사이에 단일 이미지 줄로 분리한다.
|
|
* @param {{ startLine: number, endLine: number, imageIndex: number, insertBeforeLine: number, image?: Object }} payload - 갤러리 범위·삽입 위치
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewExtractGalleryImage = ({
|
|
startLine,
|
|
endLine,
|
|
imageIndex,
|
|
insertBeforeLine,
|
|
image
|
|
}) => {
|
|
const nextValue = createPreviewExtractGalleryImageValue({
|
|
value: markdownValue.value,
|
|
startLine,
|
|
endLine,
|
|
imageIndex,
|
|
insertBeforeLine,
|
|
image
|
|
})
|
|
|
|
if (nextValue !== null) {
|
|
markdownValue.value = nextValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미리보기 인라인 편집 결과를 마크다운 본문에 반영한다.
|
|
* @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
|
|
}
|
|
|
|
if (shouldIgnorePendingMediaSlashCommit({
|
|
pendingInsertLine: liveSlashInsertLine.value,
|
|
startLine,
|
|
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 = () => {
|
|
markdownValue.value = createPreviewAppendParagraphValue(markdownValue.value)
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드에서 지정 줄 아래에 마크다운 줄을 삽입한다.
|
|
* @param {{ afterLine: number, lines: string[], focusLine?: number }} payload - 삽입 위치·줄
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewInsertAfterLine = ({ afterLine, lines, focusLine }) => {
|
|
const result = createPreviewInsertAfterLineResult({
|
|
value: markdownValue.value,
|
|
afterLine,
|
|
lines,
|
|
focusLine
|
|
})
|
|
|
|
if (!result) {
|
|
return
|
|
}
|
|
|
|
markdownValue.value = result.value
|
|
activeLogicalLineIndex.value = result.focusLine
|
|
resetBlockPanelContext()
|
|
|
|
nextTick(() => {
|
|
focusPreviewEditableAtLine(result.focusLine, 0, 'start')
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드에서 현재 줄을 삭제한다.
|
|
* @param {number} lineIndex - 줄 번호(0-based)
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewDeleteLine = (lineIndex) => {
|
|
const nextValue = createPreviewDeleteLineValue({
|
|
value: markdownValue.value,
|
|
lineIndex
|
|
})
|
|
|
|
if (nextValue !== null) {
|
|
markdownValue.value = nextValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드 미리보기 루트 단축키를 처리한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const handlePreviewKeydownCapture = (event) => {
|
|
const target = event.target
|
|
const isSelectAllShortcut = (event.metaKey || event.ctrlKey)
|
|
&& !event.shiftKey
|
|
&& !event.altKey
|
|
&& event.key.toLowerCase() === 'a'
|
|
|
|
if (isSelectAllShortcut) {
|
|
if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
selectAllPreviewDocument()
|
|
return
|
|
}
|
|
|
|
const isDeleteShortcut = (event.metaKey || event.ctrlKey)
|
|
&& event.shiftKey
|
|
&& event.key.toLowerCase() === 'k'
|
|
|
|
if (!isDeleteShortcut) {
|
|
return
|
|
}
|
|
|
|
if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) {
|
|
return
|
|
}
|
|
|
|
const lineIndex = getCurrentPreviewSourceLine()
|
|
|
|
if (typeof lineIndex !== 'number' || lineIndex < 0) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
onPreviewDeleteLine(lineIndex)
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 컴포넌트가 노출한 실제 DOM 엘리먼트를 반환한다.
|
|
* @returns {HTMLElement|null} 메뉴 엘리먼트
|
|
*/
|
|
const getLiveSlashMenuElement = () => {
|
|
const menu = liveSlashMenuRef.value
|
|
|
|
if (!menu) {
|
|
return null
|
|
}
|
|
|
|
if (typeof menu.getRootElement === 'function') {
|
|
return menu.getRootElement()
|
|
}
|
|
|
|
return menu.rootRef ?? menu
|
|
}
|
|
|
|
/**
|
|
* 미리보기 모드 슬래시 메뉴 위치를 갱신한다.
|
|
* @returns {void}
|
|
*/
|
|
const updatePreviewSlashMenuPosition = () => {
|
|
const element = getPreviewSourceLineElement(liveSlashSourceLine.value)
|
|
|
|
if (!element) {
|
|
return
|
|
}
|
|
|
|
const rect = element.getBoundingClientRect()
|
|
const menuEl = getLiveSlashMenuElement()
|
|
const menuHeight = menuEl?.offsetHeight || DEFAULT_SLASH_MENU_HEIGHT
|
|
const menuWidth = menuEl?.offsetWidth || DEFAULT_SLASH_MENU_WIDTH
|
|
const gap = 4
|
|
|
|
liveSlashMenuPosition.value = calculateSlashMenuPosition({
|
|
top: rect.bottom + gap,
|
|
left: rect.left,
|
|
menuHeight,
|
|
menuWidth,
|
|
anchorTop: rect.top,
|
|
viewportHeight: window.innerHeight,
|
|
viewportWidth: window.innerWidth
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 소스 모드 textarea 슬래시 메뉴 위치를 갱신한다.
|
|
* @returns {void}
|
|
*/
|
|
const updateWriteSlashMenuPosition = () => {
|
|
const textarea = getWriteTextareaElement()
|
|
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 = getLiveSlashMenuElement()
|
|
const menuHeight = menuEl?.offsetHeight || DEFAULT_SLASH_MENU_HEIGHT
|
|
const menuWidth = menuEl?.offsetWidth || DEFAULT_SLASH_MENU_WIDTH
|
|
const gap = 4
|
|
const anchorTop = rect.top + coords.top - textarea.scrollTop
|
|
|
|
liveSlashMenuPosition.value = calculateSlashMenuPosition({
|
|
top: anchorTop + coords.height + gap,
|
|
left: rect.left + coords.left - textarea.scrollLeft,
|
|
menuHeight,
|
|
menuWidth,
|
|
anchorTop,
|
|
viewportHeight: window.innerHeight,
|
|
viewportWidth: window.innerWidth
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 위치를 갱신한다(뷰포트 아래로 넘치면 위쪽·좌우 클램프).
|
|
* @returns {void}
|
|
*/
|
|
const updateLiveSlashMenuPosition = () => {
|
|
if (!import.meta.client || liveSlashSourceLine.value === null) {
|
|
return
|
|
}
|
|
|
|
nextTick(() => {
|
|
nextTick(() => {
|
|
if (activeMode.value === 'write') {
|
|
updateWriteSlashMenuPosition()
|
|
return
|
|
}
|
|
|
|
updatePreviewSlashMenuPosition()
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 위치 재계산 이벤트를 실행한다.
|
|
* @returns {void}
|
|
*/
|
|
const repositionLiveSlashMenu = () => {
|
|
updateLiveSlashMenuPosition()
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 위치 재계산 이벤트 리스너를 제거한다.
|
|
* @returns {void}
|
|
*/
|
|
const removeLiveSlashRepositionListeners = () => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
window.removeEventListener('scroll', repositionLiveSlashMenu, true)
|
|
window.removeEventListener('resize', repositionLiveSlashMenu)
|
|
liveSlashPreviewScrollElement?.removeEventListener('scroll', repositionLiveSlashMenu)
|
|
liveSlashTextareaScrollElement?.removeEventListener('scroll', repositionLiveSlashMenu)
|
|
liveSlashPreviewScrollElement = null
|
|
liveSlashTextareaScrollElement = null
|
|
}
|
|
|
|
/**
|
|
* 슬래시 메뉴 위치 재계산 이벤트 리스너를 등록한다.
|
|
* @returns {void}
|
|
*/
|
|
const addLiveSlashRepositionListeners = () => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
removeLiveSlashRepositionListeners()
|
|
liveSlashPreviewScrollElement = getPreviewSurfaceElement()
|
|
liveSlashTextareaScrollElement = getWriteTextareaElement()
|
|
window.addEventListener('scroll', repositionLiveSlashMenu, true)
|
|
window.addEventListener('resize', repositionLiveSlashMenu)
|
|
liveSlashPreviewScrollElement?.addEventListener('scroll', repositionLiveSlashMenu)
|
|
liveSlashTextareaScrollElement?.addEventListener('scroll', repositionLiveSlashMenu)
|
|
}
|
|
|
|
/**
|
|
* 키보드로 선택한 슬래시 메뉴 항목이 보이도록 스크롤한다.
|
|
* @returns {void}
|
|
*/
|
|
const scrollLiveSlashHighlightedIntoView = () => {
|
|
if (!import.meta.client || !liveSlashVisible.value) {
|
|
return
|
|
}
|
|
|
|
nextTick(() => {
|
|
const menu = getLiveSlashMenuElement()
|
|
|
|
if (!menu) {
|
|
return
|
|
}
|
|
|
|
const highlightedItem = menu.querySelector('.admin-markdown-editor__slash-item--active')
|
|
|
|
if (!highlightedItem) {
|
|
return
|
|
}
|
|
|
|
menu.scrollTop = calculateSlashMenuScrollTop({
|
|
itemTop: highlightedItem.offsetTop,
|
|
itemHeight: highlightedItem.offsetHeight,
|
|
viewTop: menu.scrollTop,
|
|
viewHeight: 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
|
|
}
|
|
|
|
const lines = (markdownValue.value ?? '').split('\n')
|
|
const lineValue = lines[payload.sourceLine] ?? ''
|
|
|
|
if (isLiveSlashLineSuppressed(payload.sourceLine, lineValue)) {
|
|
if (liveSlashSourceLine.value !== null) {
|
|
clearLiveSlashState()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
releaseLiveSlashLine(payload.sourceLine)
|
|
|
|
if (isCodeFenceBodyLine(lines, payload.sourceLine)) {
|
|
onLiveSlashEnd({ sourceLine: payload.sourceLine })
|
|
return
|
|
}
|
|
|
|
openLiveSlashMenu(payload.sourceLine, payload.query)
|
|
nextTick(updateLiveSlashMenuPosition)
|
|
}
|
|
|
|
watch(liveSlashQuery, () => {
|
|
resetLiveSlashHighlightedIndex()
|
|
})
|
|
|
|
watch(visibleLiveSlashCommands, (commands) => {
|
|
clampLiveSlashHighlightedIndex(commands.length)
|
|
|
|
updateLiveSlashMenuPosition()
|
|
})
|
|
|
|
watch(liveSlashHighlightedIndex, () => {
|
|
scrollLiveSlashHighlightedIntoView()
|
|
})
|
|
|
|
watch(liveSlashVisible, (visible) => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
if (visible) {
|
|
addLiveSlashRepositionListeners()
|
|
updateLiveSlashMenuPosition()
|
|
scrollLiveSlashHighlightedIntoView()
|
|
return
|
|
}
|
|
|
|
removeLiveSlashRepositionListeners()
|
|
})
|
|
|
|
/**
|
|
* 슬래시 명령 입력을 종료한다.
|
|
* @returns {void}
|
|
*/
|
|
const onLiveSlashEnd = (payload) => {
|
|
if (typeof payload?.sourceLine === 'number') {
|
|
releaseLiveSlashLine(payload.sourceLine)
|
|
}
|
|
|
|
clearLiveSlashState()
|
|
}
|
|
|
|
/**
|
|
* 슬래시 명령 입력을 취소한다(줄 내용은 유지, 메뉴만 닫음).
|
|
* @returns {void}
|
|
*/
|
|
const cancelLiveSlashCommand = () => {
|
|
const line = liveSlashSourceLine.value
|
|
|
|
if (line !== null) {
|
|
const lines = (markdownValue.value ?? '').split('\n')
|
|
suppressLiveSlashLine(line, lines[line] ?? '')
|
|
}
|
|
|
|
clearLiveSlashState()
|
|
}
|
|
|
|
/**
|
|
* 슬래시 명령 적용 후 미리보기 인라인 편집에 포커스를 복원한다.
|
|
* @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(() => {
|
|
focusPreviewEditableAtLine(focusLine, 0, 'end', offset)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 소스 모드 textarea에 슬래시 명령 적용 후 커서를 복원한다.
|
|
* @param {number} line - 줄 번호(0-based)
|
|
* @param {string[]} replacementLines - 삽입된 줄
|
|
* @returns {void}
|
|
*/
|
|
const focusTextareaAfterSlashCommand = (line, replacementLines) => {
|
|
const selection = getSlashCommandTextareaSelection({
|
|
markdown: markdownValue.value ?? '',
|
|
startLine: line,
|
|
lines: replacementLines
|
|
})
|
|
setTextareaSelection(selection.start, selection.end)
|
|
}
|
|
|
|
/**
|
|
* 슬래시 명령을 적용한다.
|
|
* @param {import('../../lib/markdown-slash-commands.js').MarkdownSlashCommand} command - 명령
|
|
* @returns {void}
|
|
*/
|
|
const applyLiveSlashCommand = (command) => {
|
|
const plan = createSlashCommandApplyPlan({
|
|
command,
|
|
sourceLine: liveSlashSourceLine.value
|
|
})
|
|
const effectPlan = createSlashCommandEditorEffectPlan({
|
|
plan,
|
|
activeMode: activeMode.value
|
|
})
|
|
|
|
if (effectPlan.kind === 'none') {
|
|
return
|
|
}
|
|
|
|
if (effectPlan.kind === 'media') {
|
|
liveSlashInsertLine.value = effectPlan.line
|
|
replaceLineRange(effectPlan.line, effectPlan.line, [], false)
|
|
onLiveSlashEnd()
|
|
openMediaPicker(effectPlan.mediaKind)
|
|
return
|
|
}
|
|
|
|
if (effectPlan.kind === 'lines') {
|
|
releaseLiveSlashLine(effectPlan.line)
|
|
replaceLineRange(effectPlan.line, effectPlan.line, effectPlan.lines, false)
|
|
clearLiveSlashState()
|
|
|
|
if (effectPlan.focusTarget === 'textarea') {
|
|
focusTextareaAfterSlashCommand(effectPlan.line, effectPlan.lines)
|
|
} else {
|
|
focusPreviewAfterSlashCommand(effectPlan.line, effectPlan.lines)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 슬래시 명령 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) {
|
|
onLiveSlashEnd({ sourceLine: payload?.sourceLine })
|
|
return
|
|
}
|
|
|
|
applyLiveSlashCommand(command)
|
|
}
|
|
|
|
/**
|
|
* 지정 줄에 마크다운 줄을 삽입한다.
|
|
* @param {number} lineIndex - 삽입 위치(0-based)
|
|
* @param {string[]} insertLines - 삽입 줄
|
|
* @returns {void}
|
|
*/
|
|
const insertMarkdownAtLine = (lineIndex, insertLines) => {
|
|
markdownValue.value = insertMarkdownLinesAtSlashTarget(markdownValue.value, lineIndex, insertLines)
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드에서 현재 줄을 이전 줄 끝에 병합한다.
|
|
* @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄
|
|
* @returns {void}
|
|
*/
|
|
const onPreviewMergeWithPreviousLine = ({ lineIndex, mergedLine }) => {
|
|
const nextValue = createPreviewMergeWithPreviousLineValue({
|
|
value: markdownValue.value,
|
|
lineIndex,
|
|
mergedLine
|
|
})
|
|
|
|
if (nextValue !== null) {
|
|
markdownValue.value = nextValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 갤러리 이미지 순서를 바꾼다.
|
|
* @param {number} imageIndex - 이동할 이미지 인덱스
|
|
* @param {-1|1} direction - 이동 방향
|
|
* @returns {void}
|
|
*/
|
|
const moveActiveGalleryImage = (imageIndex, direction) => {
|
|
const plan = createGalleryImageMovePlan(activeMediaBlock.value, imageIndex, direction)
|
|
|
|
if (!plan) {
|
|
return
|
|
}
|
|
|
|
applyLineReplacementPlan(plan, false)
|
|
}
|
|
|
|
/**
|
|
* 현재 미디어 블록에서 이미지를 삭제한다.
|
|
* @param {number} imageIndex - 삭제할 이미지 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const removeActiveMediaImage = (imageIndex) => {
|
|
applyLineReplacementPlan(
|
|
createMediaImageRemovePlan(activeMediaBlock.value, imageIndex),
|
|
false
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 현재 갤러리에 이미지를 추가한다.
|
|
* @param {Array<{ url: string, alt?: string, width?: string }>} images - 추가 이미지 목록
|
|
* @returns {void}
|
|
*/
|
|
const appendImagesToActiveGallery = (images) => {
|
|
const plan = createGalleryImagesAppendPlan(activeMediaBlock.value, images)
|
|
|
|
if (!plan) {
|
|
insertGallery(images)
|
|
return
|
|
}
|
|
|
|
applyLineReplacementPlan(plan, false)
|
|
}
|
|
|
|
/**
|
|
* 현재 링크·iframe·HTML 블록 값을 수정한다.
|
|
* @param {string} url - 블록 값
|
|
* @returns {void}
|
|
*/
|
|
const updateActiveEmbedUrl = (url) => {
|
|
const plan = createEmbedUrlReplacementPlan(activeBlockContext.value, url)
|
|
|
|
if (!plan) {
|
|
return
|
|
}
|
|
|
|
ensureBlockPanelEngaged()
|
|
applyLineReplacementPlan(plan, false)
|
|
}
|
|
|
|
/**
|
|
* 현재 인용 블록 배경색을 수정한다.
|
|
* @param {string} background - 배경색 옵션
|
|
* @returns {void}
|
|
*/
|
|
const updateActiveQuoteBackground = (background) => {
|
|
const nextValue = createQuoteBackgroundValue({
|
|
value: markdownValue.value,
|
|
block: activeBlockContext.value,
|
|
background
|
|
})
|
|
|
|
if (nextValue === null) {
|
|
return
|
|
}
|
|
|
|
ensureBlockPanelEngaged()
|
|
markdownValue.value = nextValue
|
|
}
|
|
|
|
/**
|
|
* 현재 콜아웃 블록 옵션을 수정한다.
|
|
* @param {Partial<{ calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string, title: string }>} patch - 변경 옵션
|
|
* @returns {void}
|
|
*/
|
|
const updateActiveCalloutOptions = (patch = {}) => {
|
|
const plan = createCalloutOptionsReplacementPlan(activeBlockContext.value, patch)
|
|
|
|
if (!plan) {
|
|
return
|
|
}
|
|
|
|
ensureBlockPanelEngaged()
|
|
applyLineReplacementPlan(plan, false)
|
|
}
|
|
|
|
/**
|
|
* 현재 코드 블록 옵션을 수정한다.
|
|
* @param {Partial<{ language: string, showLineNumbers: boolean }>} patch - 변경 옵션
|
|
* @returns {void}
|
|
*/
|
|
const updateActiveCodeOptions = (patch = {}) => {
|
|
const plan = createCodeOptionsReplacementPlan(activeBlockContext.value, patch)
|
|
|
|
if (!plan) {
|
|
return
|
|
}
|
|
|
|
ensureBlockPanelEngaged()
|
|
applyLineReplacementPlan(plan, false)
|
|
}
|
|
|
|
/**
|
|
* 현재 토글 블록 옵션을 수정한다.
|
|
* @param {Partial<{ defaultOpen: boolean }>} patch - 변경 옵션
|
|
* @returns {void}
|
|
*/
|
|
const updateActiveToggleOptions = (patch = {}) => {
|
|
const plan = createToggleOptionsReplacementPlan(activeBlockContext.value, patch)
|
|
|
|
if (!plan) {
|
|
return
|
|
}
|
|
|
|
ensureBlockPanelEngaged()
|
|
applyLineReplacementPlan(plan, false)
|
|
}
|
|
|
|
/**
|
|
* 현재 선택된 텍스트에 인라인 색상을 적용한다.
|
|
* @param {string} color - 색상 이름
|
|
* @returns {void}
|
|
*/
|
|
const updateActiveInlineColor = (color) => {
|
|
const context = inlineColorSelectionContext.value
|
|
|
|
if (!context || context.kind !== 'inline-color') {
|
|
return
|
|
}
|
|
|
|
ensureBlockPanelEngaged()
|
|
|
|
const sourceResult = createSourceInlineColorValue({
|
|
value: markdownValue.value,
|
|
context,
|
|
color
|
|
})
|
|
|
|
if (sourceResult) {
|
|
markdownValue.value = sourceResult.value
|
|
inlineColorSelectionContext.value = null
|
|
setTextareaSelection(sourceResult.selectionStart, sourceResult.selectionEnd, { preserveScroll: true })
|
|
return
|
|
}
|
|
|
|
const liveResult = createLiveInlineColorValue({
|
|
value: markdownValue.value,
|
|
context,
|
|
color
|
|
})
|
|
|
|
if (!liveResult) {
|
|
return
|
|
}
|
|
|
|
markdownValue.value = liveResult.value
|
|
inlineColorSelectionContext.value = null
|
|
requestPreviewFocus({
|
|
line: liveResult.focusLine,
|
|
offset: liveResult.focusOffset
|
|
})
|
|
nextTick(() => {
|
|
focusPreviewEditableAtLine(
|
|
liveResult.focusLine,
|
|
0,
|
|
'auto',
|
|
liveResult.focusOffset,
|
|
'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'))
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 창을 연다.
|
|
* @param {'image'|'gallery'|'active-gallery'} target - 삽입 대상
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const openMediaPicker = async (target) => {
|
|
if (target === 'active-gallery') {
|
|
ensureBlockPanelEngaged()
|
|
}
|
|
|
|
openMediaPickerState(target)
|
|
}
|
|
|
|
defineExpose({
|
|
focusFirstBlock,
|
|
toggleEditorMode,
|
|
activeMode,
|
|
handleBlockPanelFocusIn,
|
|
handleBlockPanelFocusOut,
|
|
updateActiveMediaImage,
|
|
setActiveMediaUseAlt,
|
|
moveActiveGalleryImage,
|
|
removeActiveMediaImage,
|
|
appendImagesToActiveGallery,
|
|
updateActiveEmbedUrl,
|
|
updateActiveQuoteBackground,
|
|
updateActiveCalloutOptions,
|
|
updateActiveCodeOptions,
|
|
updateActiveToggleOptions,
|
|
updateActiveInlineColor,
|
|
openMediaPicker
|
|
})
|
|
|
|
/**
|
|
* 미디어 선택 창을 닫는다.
|
|
* @returns {void}
|
|
*/
|
|
const closeMediaPicker = () => {
|
|
if (!closeMediaPickerState()) {
|
|
return
|
|
}
|
|
|
|
liveSlashInsertLine.value = null
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 모달 페이지를 변경한다.
|
|
* @param {number} page - 이동할 페이지
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const setMediaPickerPage = async (page) => {
|
|
setMediaPickerPageState(page)
|
|
await fetchMediaItems()
|
|
}
|
|
|
|
watch(mediaSearchQuery, async () => {
|
|
if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') {
|
|
return
|
|
}
|
|
|
|
mediaPickerPage.value = 1
|
|
await fetchMediaItems()
|
|
})
|
|
|
|
watch(activeMediaPickerTab, async (tab) => {
|
|
if (!isMediaPickerOpen.value || tab !== 'library') {
|
|
return
|
|
}
|
|
|
|
mediaPickerPage.value = 1
|
|
await fetchMediaItems()
|
|
})
|
|
|
|
/**
|
|
* 미디어 항목 목록을 마크다운에 삽입한다.
|
|
* @param {Array<Object>} selectedItems - 선택 또는 업로드된 미디어 항목
|
|
* @returns {void}
|
|
*/
|
|
const insertSelectedMediaItems = (selectedItems) => {
|
|
const plan = createSelectedMediaInsertPlan({
|
|
target: mediaPickerTarget.value,
|
|
selectedItems,
|
|
liveSlashLine: liveSlashInsertLine.value
|
|
})
|
|
|
|
if (liveSlashInsertLine.value !== null) {
|
|
if (plan.kind === 'insert-at-line') {
|
|
insertMarkdownAtLine(plan.line, plan.lines)
|
|
}
|
|
liveSlashInsertLine.value = null
|
|
return
|
|
}
|
|
|
|
if (plan.kind === 'image') {
|
|
insertImage(plan.image)
|
|
} else if (plan.kind === 'active-gallery') {
|
|
appendImagesToActiveGallery(plan.images)
|
|
} else if (plan.kind === 'gallery') {
|
|
insertGallery(plan.images)
|
|
} else if (plan.kind === 'block') {
|
|
insertBlockSnippet(plan.lines.join('\n'))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 선택한 미디어를 마크다운에 삽입한다.
|
|
* @returns {void}
|
|
*/
|
|
const applyMediaSelection = () => {
|
|
const selectedItems = resolveCurrentSelectedMediaItems()
|
|
insertSelectedMediaItems(selectedItems)
|
|
closeMediaPicker()
|
|
}
|
|
|
|
/**
|
|
* 업로드 파일을 현재 커서 위치에 삽입한다.
|
|
* @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
|
|
}
|
|
|
|
try {
|
|
const uploadedFiles = await uploadMediaFilesWithState(files)
|
|
|
|
if (!uploadedFiles.length) {
|
|
return
|
|
}
|
|
|
|
const plan = createSelectedMediaInsertPlan({
|
|
target,
|
|
selectedItems: uploadedFiles,
|
|
liveSlashLine: null
|
|
})
|
|
|
|
if (plan.kind === 'gallery') {
|
|
insertGallery(plan.images)
|
|
} else if (plan.kind === 'image') {
|
|
insertImage(plan.image)
|
|
} else if (plan.kind === 'block') {
|
|
insertBlockSnippet(plan.lines.join('\n'))
|
|
}
|
|
|
|
if (isMediaPickerOpen.value && activeMediaPickerTab.value === 'library') {
|
|
await fetchMediaItems()
|
|
}
|
|
} catch (error) {
|
|
showToast('error', resolveUploadFetchErrorMessage(error))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 파일 입력 변경 처리
|
|
* @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
|
|
}
|
|
|
|
try {
|
|
const uploadedFiles = await uploadFilesIntoMediaPicker(files)
|
|
|
|
if (!uploadedFiles.length) {
|
|
return
|
|
}
|
|
|
|
showToast('success', '업로드가 완료되어 파일이 선택되었습니다.')
|
|
} catch (error) {
|
|
showToast('error', resolveUploadFetchErrorMessage(error))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 모달 업로드 영역에 파일을 드롭한다.
|
|
* @param {DragEvent} event - 드롭 이벤트
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handleMediaModalDrop = async (event) => {
|
|
if (isUploading.value) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
|
|
const files = Array.from(event.dataTransfer?.files || []).filter(isUploadFileAllowedForPicker)
|
|
|
|
if (!files.length) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
await uploadFromMediaModal(files)
|
|
}
|
|
|
|
/**
|
|
* 붙여넣은 이미지 파일을 업로드한다.
|
|
* @param {ClipboardEvent} event - 붙여넣기 이벤트
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handlePaste = async (event) => {
|
|
if (isUploading.value) {
|
|
return
|
|
}
|
|
|
|
const imageFiles = filterImageUploadFiles(event.clipboardData?.files)
|
|
|
|
if (!imageFiles.length) {
|
|
const html = event.clipboardData?.getData('text/html')
|
|
|
|
if (html) {
|
|
const markdown = convertHtmlToMarkdown(html)
|
|
|
|
if (markdown) {
|
|
event.preventDefault()
|
|
replaceSelection(markdown)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
await uploadAndInsert(imageFiles, resolveImageUploadInsertTarget(imageFiles))
|
|
}
|
|
|
|
/**
|
|
* 드롭한 이미지 파일을 업로드한다.
|
|
* @param {DragEvent} event - 드롭 이벤트
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handleDrop = async (event) => {
|
|
if (isUploading.value) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
|
|
const imageFiles = filterImageUploadFiles(event.dataTransfer?.files)
|
|
|
|
if (!imageFiles.length) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
await uploadAndInsert(imageFiles, resolveImageUploadInsertTarget(imageFiles))
|
|
}
|
|
|
|
/**
|
|
* 키보드 단축키를 처리한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const handleKeydown = (event) => {
|
|
if (!(event.metaKey || event.ctrlKey)) {
|
|
return
|
|
}
|
|
|
|
const key = event.key.toLowerCase()
|
|
|
|
if (event.shiftKey && key === 'k') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
deleteSelectedSourceLines()
|
|
} else if (key === 'k') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
insertInlineLink()
|
|
} else if (key === 'b') {
|
|
event.preventDefault()
|
|
wrapInline('**', '**', '굵은 글씨')
|
|
} else if (key === 'i') {
|
|
event.preventDefault()
|
|
wrapInline('*', '*', '기울임')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="editorRootRef" class="admin-markdown-editor grid gap-3">
|
|
<AdminMarkdownModeToggle
|
|
:active-mode="activeMode"
|
|
:teleport-to="modeToggleTeleportTo"
|
|
@toggle="toggleEditorMode"
|
|
/>
|
|
|
|
<AdminMarkdownWriteSurface
|
|
v-if="activeMode === 'write'"
|
|
ref="writeSurfaceRef"
|
|
v-model="markdownValue"
|
|
:gutter-line-count="gutterLineCount"
|
|
:get-gutter-line-height="getGutterLineHeight"
|
|
:is-uploading="isUploading"
|
|
@keydown="handleKeydown"
|
|
@paste="handlePaste"
|
|
@drop="handleDrop"
|
|
@input="refreshCaretLogicalLine"
|
|
@compositionstart="handleTextareaCompositionStart"
|
|
@compositionend="handleTextareaCompositionEnd"
|
|
@click="refreshCaretLogicalLine"
|
|
@keyup="refreshCaretLogicalLine"
|
|
@select="refreshCaretLogicalLine"
|
|
@focus="onTextareaFocus"
|
|
@blur="onTextareaBlur"
|
|
/>
|
|
|
|
<AdminMarkdownPreviewSurface
|
|
v-else
|
|
ref="previewSurfaceRef"
|
|
:content="markdownValue"
|
|
:slash-menu-active="liveSlashVisible"
|
|
:slash-suppressed-lines="liveSlashSuppressedLineList"
|
|
@keydown="handlePreviewKeydownCapture"
|
|
@gallery-reorder="onPreviewGalleryReorder"
|
|
@merge-images-to-gallery="onPreviewMergeImagesToGallery"
|
|
@insert-image-to-gallery="onPreviewInsertImageToGallery"
|
|
@extract-gallery-image="onPreviewExtractGalleryImage"
|
|
@remove-gallery-image="onPreviewRemoveGalleryImage"
|
|
@block-content-change="onPreviewBlockContentChange"
|
|
@content-replace="onPreviewContentReplace"
|
|
@append-paragraph="onPreviewAppendParagraph"
|
|
@insert-after-line="onPreviewInsertAfterLine"
|
|
@delete-line="onPreviewDeleteLine"
|
|
@merge-with-previous-line="onPreviewMergeWithPreviousLine"
|
|
@line-focus="handleLiveLineFocus"
|
|
@line-blur="handleLiveLineBlur"
|
|
@slash-update="onLiveSlashUpdate"
|
|
@slash-end="onLiveSlashEnd"
|
|
@slash-apply="onLiveSlashApply"
|
|
/>
|
|
|
|
<Teleport to="body">
|
|
<AdminMarkdownSlashMenu
|
|
v-if="liveSlashVisible"
|
|
ref="liveSlashMenuRef"
|
|
:commands="visibleLiveSlashCommands"
|
|
:highlighted-index="liveSlashHighlightedIndex"
|
|
:position="liveSlashMenuPosition"
|
|
@highlight="onSlashMenuItemMouseEnter"
|
|
@select="applyLiveSlashCommand"
|
|
/>
|
|
</Teleport>
|
|
|
|
<AdminMarkdownMediaPickerModal
|
|
v-if="isMediaPickerOpen"
|
|
:title="mediaPickerTitle"
|
|
:active-tab="activeMediaPickerTab"
|
|
:search-input="mediaSearchInput"
|
|
:media-items="filteredMediaItems"
|
|
:selected-urls="selectedMediaUrls"
|
|
:is-loading="isLoadingMedia"
|
|
:is-uploading="isUploading"
|
|
:accept="mediaPickerAccept"
|
|
:multiple="isGalleryMediaPicker"
|
|
:upload-hint="mediaPickerUploadHint"
|
|
:empty-message="mediaPickerEmptyMessage"
|
|
:range-label="mediaPickerRangeLabel"
|
|
:page="mediaPickerPage"
|
|
:page-count="mediaPickerPageCount"
|
|
:page-items="mediaPickerPageItems"
|
|
:apply-state="mediaPickerApplyState"
|
|
:get-selection-order="getGallerySelectionOrder"
|
|
@apply="applyMediaSelection"
|
|
@close="closeMediaPicker"
|
|
@drop="handleMediaModalDrop"
|
|
@image-error="onMediaPickerImageError($event.event, $event.item)"
|
|
@page="setMediaPickerPage"
|
|
@search-composition-end="onMediaSearchCompositionEnd"
|
|
@search-input="onMediaSearchInput"
|
|
@tab="activeMediaPickerTab = $event"
|
|
@toggle="toggleMediaSelection"
|
|
@upload="uploadFromMediaModal"
|
|
/>
|
|
</div>
|
|
|
|
<AdminToast :toast="toast" />
|
|
</template>
|