v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선
라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기, 사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,17 +3,20 @@ import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.
|
||||
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
|
||||
import { getImageDefaultAltLabel, serializeImageMarkdown } from '../../lib/markdown-image.js'
|
||||
import { convertHtmlToMarkdown } from '../../lib/markdown-inline.js'
|
||||
import {
|
||||
filterSlashCommands,
|
||||
getSlashCommandFocusTarget,
|
||||
parseSlashInput,
|
||||
resolveSlashCommand
|
||||
} from '../../lib/markdown-slash-commands.js'
|
||||
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
||||
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Array, Object],
|
||||
default: ''
|
||||
},
|
||||
/** 헤더 아래 고정 툴바 슬롯(AdminPostForm `#admin-post-form-editor-toolbar-host`) */
|
||||
toolbarTeleportTo: {
|
||||
type: String,
|
||||
default: '#admin-post-form-editor-toolbar-host'
|
||||
},
|
||||
/** 작성/미리보기 토글 버튼 Teleport 대상(AdminPostForm 헤더) */
|
||||
modeToggleTeleportTo: {
|
||||
type: String,
|
||||
@@ -30,6 +33,7 @@ const activeMode = defineModel('editorMode', {
|
||||
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)
|
||||
@@ -45,6 +49,32 @@ 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))
|
||||
|
||||
/** 작성 textarea 최소 높이(px) */
|
||||
const MIN_TEXTAREA_HEIGHT_PX = 620
|
||||
@@ -215,9 +245,47 @@ const refreshCaretLogicalLine = () => {
|
||||
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')
|
||||
const parsed = parseSlashInput(lines[line] ?? '')
|
||||
|
||||
if (!parsed) {
|
||||
onLiveSlashEnd({ sourceLine: line })
|
||||
return
|
||||
}
|
||||
|
||||
liveSlashSourceLine.value = line
|
||||
liveSlashQuery.value = parsed.query
|
||||
nextTick(updateLiveSlashMenuPosition)
|
||||
}
|
||||
|
||||
/**
|
||||
* textarea의 선택 영역과 스크롤 위치를 기억한다.
|
||||
* @returns {void}
|
||||
@@ -341,12 +409,58 @@ onMounted(() => {
|
||||
* @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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -360,6 +474,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(blockPanelFocusTimer)
|
||||
window.clearTimeout(liveSlashKeyboardNavTimer)
|
||||
document.removeEventListener('selectionchange', onSelectionChange)
|
||||
document.removeEventListener('keydown', onDocumentKeydown, true)
|
||||
})
|
||||
@@ -439,25 +554,6 @@ const replaceSelection = (replacement, cursorOffset = replacement.length, select
|
||||
setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter 입력을 문단/줄바꿈 규칙에 맞게 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {boolean} 직접 처리했는지 여부
|
||||
*/
|
||||
const handleParagraphEnter = (event) => {
|
||||
if (event.key !== 'Enter' || event.metaKey || event.ctrlKey || event.altKey || event.isComposing) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!event.shiftKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
replaceSelection('\\\n')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
|
||||
* @param {string} snippet - 삽입할 마크다운
|
||||
@@ -737,6 +833,392 @@ const onPreviewDeleteLine = (lineIndex) => {
|
||||
markdownValue.value = nextLines.length ? nextLines.join('\n') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드에서 현재 줄을 이전 줄 끝에 병합한다.
|
||||
* @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
|
||||
}
|
||||
|
||||
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(() => {
|
||||
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 (command.action === 'media-image' || command.action === 'media-gallery') {
|
||||
liveSlashInsertLine.value = line
|
||||
replaceLineRange(line, line, [], false)
|
||||
onLiveSlashEnd()
|
||||
openMediaPicker(command.action === 'media-image' ? 'image' : 'gallery')
|
||||
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 - 이동할 이미지 인덱스
|
||||
@@ -894,6 +1376,7 @@ const closeMediaPicker = () => {
|
||||
selectedMediaUrls.value = []
|
||||
activeMediaPickerTab.value = 'library'
|
||||
mediaSearchQuery.value = ''
|
||||
liveSlashInsertLine.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -950,6 +1433,36 @@ const applyMediaSelection = () => {
|
||||
.map((url) => mediaItems.value.find((item) => item.url === url))
|
||||
.filter(Boolean)
|
||||
|
||||
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 (selectedItems.length) {
|
||||
insertMarkdownAtLine(line, [
|
||||
':::gallery',
|
||||
...selectedItems.map((item) => createImageMarkdown({
|
||||
url: item.url,
|
||||
useAlt: false,
|
||||
caption: ''
|
||||
})),
|
||||
':::'
|
||||
])
|
||||
}
|
||||
|
||||
liveSlashInsertLine.value = null
|
||||
closeMediaPicker()
|
||||
return
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'image') {
|
||||
const [item] = selectedItems
|
||||
if (item) {
|
||||
@@ -1141,10 +1654,6 @@ const handleDrop = async (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleKeydown = (event) => {
|
||||
if (handleParagraphEnter(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(event.metaKey || event.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
@@ -1163,49 +1672,6 @@ const handleKeydown = (event) => {
|
||||
|
||||
<template>
|
||||
<div ref="editorRootRef" class="admin-markdown-editor grid gap-3">
|
||||
<Teleport v-if="activeMode === 'write'" :to="toolbarTeleportTo">
|
||||
<div class="admin-markdown-editor__toolbar flex w-full min-h-[40px] items-center gap-1.5 border-b border-[#e3e6e8] bg-white px-8 py-1.5">
|
||||
<div class="admin-markdown-editor__toolbar-tools flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(1)">
|
||||
H1
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(2)">
|
||||
H2
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(3)">
|
||||
H3
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-bold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('**', '**', '굵은 글씨')">
|
||||
B
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm italic text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('*', '*', '기울임')">
|
||||
I
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 font-mono text-sm text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('`', '`', 'code')">
|
||||
Code
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyQuote">
|
||||
인용
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyList">
|
||||
목록
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertCodeBlock">
|
||||
코드블록
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertBlockSnippet('---')">
|
||||
구분선
|
||||
</button>
|
||||
<span class="admin-markdown-editor__divider mx-1 h-6 w-px bg-[#e3e6e8]" aria-hidden="true" />
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('image')">
|
||||
이미지
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('gallery')">
|
||||
갤러리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport v-if="modeToggleTeleportTo" :to="modeToggleTeleportTo">
|
||||
<button
|
||||
@@ -1299,16 +1765,61 @@ const handleKeydown = (event) => {
|
||||
tabindex="0"
|
||||
>
|
||||
<ContentMarkdownRenderer
|
||||
ref="previewRendererRef"
|
||||
:content="markdownValue"
|
||||
interactive
|
||||
:slash-menu-active="liveSlashVisible"
|
||||
:slash-suppressed-lines="liveSlashSuppressedLineList"
|
||||
@gallery-reorder="onPreviewGalleryReorder"
|
||||
@block-content-change="onPreviewBlockContentChange"
|
||||
@append-paragraph="onPreviewAppendParagraph"
|
||||
@insert-after-line="onPreviewInsertAfterLine"
|
||||
@delete-line="onPreviewDeleteLine"
|
||||
@merge-with-previous-line="onPreviewMergeWithPreviousLine"
|
||||
@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 max-h-[86vh] 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">
|
||||
|
||||
Reference in New Issue
Block a user