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">
|
||||
|
||||
@@ -1338,12 +1338,6 @@ defineExpose({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-show="editorMode === 'write'"
|
||||
id="admin-post-form-editor-toolbar-host"
|
||||
class="admin-post-form__editor-toolbar-host min-h-[40px] shrink-0 bg-white"
|
||||
/>
|
||||
|
||||
<main class="admin-post-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<section class="admin-post-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-16">
|
||||
<div class="admin-post-form__feature-block mb-9">
|
||||
|
||||
135
components/admin/AdminSlashCommandIcon.vue
Normal file
135
components/admin/AdminSlashCommandIcon.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 슬래시 명령 메뉴 아이콘 (Ghost 스타일 라인 아이콘)
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** @type {import('vue').PropType<string>} */
|
||||
commandId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="admin-slash-command-icon"
|
||||
:class="`admin-slash-command-icon--${commandId}`"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- image -->
|
||||
<template v-if="commandId === 'image'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="m19.642 16.276-3.85-7a.517.517 0 0 0-.181-.189.585.585 0 0 0-.749.115l-4.533 5.494-2.307-2.516a.548.548 0 0 0-.206-.14.598.598 0 0 0-.499.031.529.529 0 0 0-.183.164l-2.75 4a.468.468 0 0 0-.015.507.526.526 0 0 0 .202.189c.084.045.18.069.28.069H19.15a.594.594 0 0 0 .268-.063.532.532 0 0 0 .2-.174.462.462 0 0 0 .024-.487ZM9.25 9c.911 0 1.65-.672 1.65-1.5S10.161 6 9.25 6c-.91 0-1.65.672-1.65 1.5S8.34 9 9.25 9Z"></path>
|
||||
</template>
|
||||
|
||||
<!-- gallery -->
|
||||
<template v-else-if="commandId === 'gallery'">
|
||||
<g clip-path="url(#a)"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 5H3.2C1.985 5 1 5.806 1 6.8v14.4c0 .994.985 1.8 2.2 1.8h17.6c1.215 0 2.2-.806 2.2-1.8V6.8c0-.994-.985-1.8-2.2-1.8ZM6 1h12"></path><path fill="currentColor" d="M15.142 10.264a.75.75 0 0 1 .529.4l4 8A.75.75 0 0 1 19 19.75H6a.75.75 0 0 1-.498-1.31l9-8a.75.75 0 0 1 .64-.176ZM7 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"></path></clipPath></defs>
|
||||
</template>
|
||||
|
||||
<!-- h1 -->
|
||||
<template v-else-if="commandId === 'h1'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M17.926 6.578v10.898c0 .602.33.963.862.963.541 0 .862-.351.862-.963V5.726c0-.682-.451-1.153-1.093-1.153-.39 0-.742.15-1.373.622l-2.026 1.504c-.4.29-.591.561-.591.852 0 .38.3.692.672.692.22 0 .43-.08.721-.291l1.885-1.374zM4.42 4.903a.77.77 0 0 1 .77.77v5.35h6.168v-5.35a.77.77 0 1 1 1.54 0v12.242a.77.77 0 0 1-1.54 0v-5.351H5.19v5.351a.77.77 0 1 1-1.54 0V5.673a.77.77 0 0 1 .77-.77"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h2 -->
|
||||
<template v-else-if="commandId === 'h2'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M14.159 16c-.562.638-.725.905-.725 1.248 0 .553.439.886 1.135.886h6.289c.524 0 .829-.276.829-.724 0-.457-.324-.734-.83-.734H15.57v-.114l3.526-4.031c1.715-1.953 2.201-2.859 2.201-4.098 0-2.096-1.648-3.583-3.993-3.583-2.515 0-4.088 1.697-4.088 3.317 0 .514.305.867.772.867.39 0 .658-.258.791-.763.286-1.238 1.191-1.972 2.42-1.972 1.449 0 2.411.896 2.411 2.24 0 .895-.41 1.695-1.486 2.925zM3.419 5.364c.404 0 .731.327.731.731v5.087h5.863V6.095a.732.732 0 1 1 1.464 0v11.637a.732.732 0 0 1-1.464 0v-5.087H4.15v5.087a.732.732 0 1 1-1.463 0V6.095c0-.404.327-.731.732-.731"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h3 -->
|
||||
<template v-else-if="commandId === 'h3'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4.05 5.856a.75.75 0 0 0-1.5 0v11.921a.75.75 0 1 0 1.5 0v-5.21h6.006v5.21a.75.75 0 0 0 1.5 0V5.856a.75.75 0 0 0-1.5 0v5.21H4.05zm9.479 9.234c-.418 0-.713.304-.713.732 0 1.454 1.796 2.936 4.248 2.936 2.642 0 4.486-1.558 4.486-3.782 0-1.635-1.226-3.041-2.832-3.222v-.095c1.321-.228 2.395-1.596 2.395-3.031 0-1.977-1.692-3.393-4.068-3.393-2.338 0-3.925 1.425-3.925 2.898 0 .476.285.79.723.79.37 0 .608-.2.798-.723.38-.979 1.235-1.54 2.366-1.54 1.454 0 2.433.875 2.433 2.177s-1.007 2.242-2.395 2.242h-1.121c-.456 0-.76.295-.76.713 0 .409.323.723.76.723h1.188c1.654 0 2.765.978 2.765 2.432s-1.083 2.386-2.784 2.386c-1.293 0-2.281-.57-2.775-1.587-.247-.485-.456-.656-.789-.656"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h4 -->
|
||||
<template v-else-if="commandId === 'h4'">
|
||||
<path fill="currentColor" d="M140-290v-380h60v160h180v-160h60v380h-60v-160H200v160h-60Zm580 0v-120H520v-260h60v200h140v-200h60v200h80v60h-80v120h-60Z"/>
|
||||
</template>
|
||||
|
||||
<!-- quote -->
|
||||
<template v-else-if="commandId === 'quote'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M5 10.966v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024H6.704A3.35 3.35 0 0 1 9.845 7.75v-1.5A4.845 4.845 0 0 0 5 10.966m8 0v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024h-3.415a3.35 3.35 0 0 1 3.141-2.192v-1.5A4.845 4.845 0 0 0 13 10.966"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- list -->
|
||||
<template v-else-if="commandId === 'list'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4.3 7.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2m4.033-1.75a.75.75 0 0 0 0 1.5l11.917.001a.75.75 0 0 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 1 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 0 0 0-1.5zM5.3 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1 6.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- code -->
|
||||
<template v-else-if="commandId === 'code'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 6A2.75 2.75 0 0 1 5 3.25h14A2.75 2.75 0 0 1 21.75 6v12A2.75 2.75 0 0 1 19 20.75H5A2.75 2.75 0 0 1 2.25 18zM5 4.75c-.69 0-1.25.56-1.25 1.25v12c0 .69.56 1.25 1.25 1.25h14c.69 0 1.25-.56 1.25-1.25V6c0-.69-.56-1.25-1.25-1.25zm5.53 4.62a.75.75 0 0 1 0 1.06l-1.59 1.591 1.59 1.591a.75.75 0 0 1-1.06 1.06l-2.122-2.12a.75.75 0 0 1 0-1.061L9.47 9.37a.75.75 0 0 1 1.06 0m2.94 1.06a.75.75 0 1 1 1.06-1.06l2.122 2.12a.75.75 0 0 1 0 1.062l-2.122 2.12a.75.75 0 1 1-1.06-1.06l1.59-1.59z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- divider -->
|
||||
<template v-else-if="commandId === 'divider'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4 4.25a.75.75 0 0 1 .75.75v1c0 .69.56 1.25 1.25 1.25h12c.69 0 1.25-.56 1.25-1.25V5a.75.75 0 0 1 1.5 0v1A2.75 2.75 0 0 1 18 8.75H6A2.75 2.75 0 0 1 3.25 6V5A.75.75 0 0 1 4 4.25m0 15.5a.75.75 0 0 0 .75-.75v-1c0-.69.56-1.25 1.25-1.25h12c.69 0 1.25.56 1.25 1.25v1a.75.75 0 0 0 1.5 0v-1A2.75 2.75 0 0 0 18 15.25H6A2.75 2.75 0 0 0 3.25 18v1c0 .414.336.75.75.75m-1-8.5a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5H3m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5H7.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5h-1.2m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5h-1.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5H21a.75.75 0 0 0 0-1.5h-1.2"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- callout -->
|
||||
<template v-else-if="commandId === 'callout'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="M12 18a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.5v6"></path>
|
||||
</template>
|
||||
|
||||
<!-- toggle -->
|
||||
<template v-else-if="commandId === 'toggle'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 11 12 15l-4.5-4"></path>
|
||||
</template>
|
||||
|
||||
<!-- embed -->
|
||||
<template v-else-if="commandId === 'embed'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm.5 16c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V9.8l4.7-5.3H19c.3 0 .5.2.5.5v14zm-6-9.5L16 12l-2.5 2.8 1.1 1L18 12l-3.5-3.5-1 1zm-3 0l-1-1L6 12l3.5 3.8 1.1-1L8 12l2.5-2.5z"></path>
|
||||
</template>
|
||||
|
||||
<!-- fallback -->
|
||||
<template v-else>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M12 4.5c.46 0 .833.373.833.833v5.834h5.834a.833.833 0 0 1 0 1.666h-5.834v5.834a.833.833 0 0 1-1.666 0v-5.834H5.333a.833.833 0 0 1 0-1.666h5.834V5.333c0-.46.373-.833.833-.833"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
257
components/content/ContentMarkdownCalloutEditor.vue
Normal file
257
components/content/ContentMarkdownCalloutEditor.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<script setup>
|
||||
import {
|
||||
buildCalloutOpenerLine,
|
||||
CALLOUT_BACKGROUND_OPTIONS,
|
||||
CALLOUT_EMOJI_OPTIONS
|
||||
} from '../../lib/markdown-callout.js'
|
||||
import ProseCallout from './ProseCallout.vue'
|
||||
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 콜아웃 본문 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
calloutEmojiEnabled: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
calloutEmoji: {
|
||||
type: String,
|
||||
default: '💡'
|
||||
},
|
||||
calloutBackground: {
|
||||
type: String,
|
||||
default: 'blue'
|
||||
},
|
||||
/** 본문 첫 줄 source-line(0-based) */
|
||||
bodySourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['commit'])
|
||||
|
||||
const settingsOpen = ref(false)
|
||||
const emojiDraft = ref(props.calloutEmoji)
|
||||
const emojiEnabled = ref(props.calloutEmojiEnabled)
|
||||
const background = ref(props.calloutBackground)
|
||||
|
||||
/** @type {import('vue').ComputedRef<Array<{ value: string, label: string }>>} */
|
||||
const backgroundOptions = computed(() => CALLOUT_BACKGROUND_OPTIONS.map((value) => ({
|
||||
value,
|
||||
label: value
|
||||
})))
|
||||
|
||||
/** @type {import('vue').ComputedRef<string[]>} */
|
||||
const emojiOptions = computed(() => CALLOUT_EMOJI_OPTIONS)
|
||||
|
||||
watch(() => props.calloutEmoji, (value) => {
|
||||
emojiDraft.value = value
|
||||
})
|
||||
|
||||
watch(() => props.calloutEmojiEnabled, (value) => {
|
||||
emojiEnabled.value = value
|
||||
})
|
||||
|
||||
watch(() => props.calloutBackground, (value) => {
|
||||
background.value = value
|
||||
})
|
||||
|
||||
/**
|
||||
* 콜아웃 마크다운 줄을 반영한다.
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const commitCallout = (body) => {
|
||||
const contentLines = String(body ?? '').replace(/\r/g, '').split('\n')
|
||||
|
||||
emit('commit', [
|
||||
buildCalloutOpenerLine({
|
||||
calloutEmojiEnabled: emojiEnabled.value,
|
||||
calloutEmoji: emojiDraft.value,
|
||||
calloutBackground: background.value
|
||||
}),
|
||||
...contentLines,
|
||||
':::'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 편집 반영
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyCommit = (body) => {
|
||||
commitCallout(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openSettings = () => {
|
||||
settingsOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeSettings = () => {
|
||||
settingsOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 이모지 표시를 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleEmoji = () => {
|
||||
emojiEnabled.value = !emojiEnabled.value
|
||||
commitCallout(props.modelValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배경색을 변경한다.
|
||||
* @param {string} value - 배경 키
|
||||
* @returns {void}
|
||||
*/
|
||||
const setBackground = (value) => {
|
||||
background.value = value
|
||||
commitCallout(props.modelValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리셋 이모지를 선택한다.
|
||||
* @param {string} value - 이모지
|
||||
* @returns {void}
|
||||
*/
|
||||
const setEmoji = (value) => {
|
||||
emojiDraft.value = value
|
||||
emojiEnabled.value = true
|
||||
commitCallout(props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-markdown-callout-editor relative">
|
||||
<ProseCallout
|
||||
:emoji-enabled="false"
|
||||
:background="background"
|
||||
>
|
||||
<div class="content-markdown-callout-editor__inner flex items-start gap-2">
|
||||
<button
|
||||
class="content-markdown-callout-editor__emoji-btn inline-flex size-9 shrink-0 items-center justify-center rounded-md text-xl text-[var(--site-text)] transition-colors hover:bg-black/10"
|
||||
type="button"
|
||||
aria-label="콜아웃 아이콘·색상 설정"
|
||||
@mousedown.prevent
|
||||
@click.stop="openSettings"
|
||||
>
|
||||
<span v-if="emojiEnabled">{{ emojiDraft || '💡' }}</span>
|
||||
<span v-else class="text-base text-[#8e9cac]">+</span>
|
||||
</button>
|
||||
<ContentMarkdownEditableInline
|
||||
block-class="content-markdown-callout-editor__body min-w-0 flex-1 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
enter-mode="multiline"
|
||||
:source-line="bodySourceLine"
|
||||
:model-value="modelValue"
|
||||
@commit="onBodyCommit"
|
||||
/>
|
||||
</div>
|
||||
</ProseCallout>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="settingsOpen"
|
||||
class="content-markdown-callout-editor__modal fixed inset-0 z-[80] grid place-items-center bg-black/35 px-4"
|
||||
@click.self="closeSettings"
|
||||
>
|
||||
<section
|
||||
class="content-markdown-callout-editor__panel w-full max-w-sm rounded-lg bg-white p-6 shadow-xl"
|
||||
role="dialog"
|
||||
aria-labelledby="callout-settings-title"
|
||||
@click.stop
|
||||
>
|
||||
<header class="content-markdown-callout-editor__panel-header mb-4 flex items-center justify-between gap-3">
|
||||
<h2 id="callout-settings-title" class="text-base font-semibold text-[#15171a]">
|
||||
콜아웃 설정
|
||||
</h2>
|
||||
<button
|
||||
class="content-markdown-callout-editor__close rounded px-2 py-1 text-sm text-[#6b7280] hover:bg-[#f1f3f4]"
|
||||
type="button"
|
||||
@click="closeSettings"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="content-markdown-callout-editor__icon-toggle mb-4 flex items-center justify-between gap-3">
|
||||
<span class="text-sm font-medium text-[#15171a]">아이콘</span>
|
||||
<button
|
||||
class="content-markdown-callout-editor__switch relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
|
||||
:class="emojiEnabled ? 'bg-[#15171a]' : 'bg-[#d0d7de]'"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="emojiEnabled"
|
||||
@click="toggleEmoji"
|
||||
>
|
||||
<span
|
||||
class="content-markdown-callout-editor__switch-knob pointer-events-none inline-block size-5 rounded-full bg-white shadow transition-transform duration-200 ease-out"
|
||||
:class="emojiEnabled ? 'translate-x-[22px]' : 'translate-x-0.5'"
|
||||
/>
|
||||
<span class="sr-only">{{ emojiEnabled ? '아이콘 표시' : '아이콘 숨김' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="emojiEnabled"
|
||||
class="content-markdown-callout-editor__emoji-presets mb-4"
|
||||
>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li
|
||||
v-for="emoji in emojiOptions"
|
||||
:key="`callout-emoji-${emoji}`"
|
||||
>
|
||||
<button
|
||||
class="content-markdown-callout-editor__emoji-swatch inline-flex size-9 items-center justify-center rounded-full border-2 text-xl transition-transform"
|
||||
:class="emojiDraft === emoji ? 'border-[#22c55e] scale-110 bg-[#f6f7f8]' : 'border-transparent hover:bg-[#f1f3f4]'"
|
||||
type="button"
|
||||
:aria-label="`이모지 ${emoji}`"
|
||||
@click="setEmoji(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content-markdown-callout-editor__colors">
|
||||
<p class="mb-2 text-sm font-medium text-[#15171a]">배경색</p>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="option in backgroundOptions" :key="`callout-bg-${option.value}`">
|
||||
<button
|
||||
class="content-markdown-callout-editor__color-swatch size-8 rounded-full border-2 transition-transform"
|
||||
:class="background === option.value ? 'border-[#22c55e] scale-110' : 'border-transparent'"
|
||||
type="button"
|
||||
:aria-label="option.label"
|
||||
:style="{
|
||||
background: option.value === 'gray' ? 'rgba(100,116,139,0.28)'
|
||||
: option.value === 'blue' ? 'rgba(59,130,246,0.3)'
|
||||
: option.value === 'green' ? 'rgba(34,197,94,0.3)'
|
||||
: option.value === 'yellow' ? 'rgba(245,158,11,0.34)'
|
||||
: option.value === 'red' ? 'rgba(239,68,68,0.3)'
|
||||
: option.value === 'purple' ? 'rgba(168,85,247,0.3)'
|
||||
: 'rgba(236,72,153,0.3)'
|
||||
}"
|
||||
@click="setBackground(option.value)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
185
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
185
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { buildCodeBlockLines } from '../../lib/markdown-code-block.js'
|
||||
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 코드 본문 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 언어(slug) */
|
||||
language: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 줄 번호 표시 */
|
||||
showLineNumbers: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/** 본문 첫 줄 source-line(0-based) */
|
||||
bodySourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['commit', 'insert-below'])
|
||||
|
||||
const languageDraft = ref(props.language)
|
||||
const lineNumbersEnabled = ref(props.showLineNumbers)
|
||||
const liveBody = ref(props.modelValue)
|
||||
|
||||
watch(() => props.language, (value) => {
|
||||
languageDraft.value = value
|
||||
})
|
||||
|
||||
watch(() => props.showLineNumbers, (value) => {
|
||||
lineNumbersEnabled.value = value
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
liveBody.value = value
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<string[]>} */
|
||||
const bodyLines = computed(() => {
|
||||
const text = String(liveBody.value ?? '')
|
||||
|
||||
if (!text.length) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
return text.split('\n')
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<number[]>} */
|
||||
const gutterLines = computed(() => bodyLines.value.map((_, index) => index + 1))
|
||||
|
||||
/**
|
||||
* 마크다운에 코드 블록을 반영한다.
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const commitCodeBlock = (body) => {
|
||||
emit('commit', buildCodeBlockLines({
|
||||
language: languageDraft.value,
|
||||
showLineNumbers: lineNumbersEnabled.value,
|
||||
body
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 편집 반영
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyCommit = (body) => {
|
||||
liveBody.value = body
|
||||
commitCodeBlock(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 중 줄 번호 갱신용 본문 동기화
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyInput = (body) => {
|
||||
liveBody.value = body
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 블록 아래로 이탈(다음 문단 생성)
|
||||
* @param {Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onExitBelow = (payload) => {
|
||||
emit('insert-below', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 언어 입력 반영
|
||||
* @returns {void}
|
||||
*/
|
||||
const onLanguageCommit = () => {
|
||||
commitCodeBlock(props.modelValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄 번호 표시를 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleLineNumbers = () => {
|
||||
lineNumbersEnabled.value = !lineNumbersEnabled.value
|
||||
commitCodeBlock(props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProseCodeBlock
|
||||
class="content-markdown-code-block-editor"
|
||||
:show-line-numbers="lineNumbersEnabled"
|
||||
:line-numbers="gutterLines"
|
||||
>
|
||||
<template #header-tools>
|
||||
<div
|
||||
class="content-markdown-code-block-editor__toolbar pointer-events-none flex items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<button
|
||||
class="content-markdown-code-block-editor__line-numbers pointer-events-auto rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||
type="button"
|
||||
:aria-pressed="lineNumbersEnabled"
|
||||
:title="lineNumbersEnabled ? '줄 번호 숨기기' : '줄 번호 표시'"
|
||||
@mousedown.prevent
|
||||
@click="toggleLineNumbers"
|
||||
>
|
||||
{{ lineNumbersEnabled ? '줄번호' : '줄번호 끔' }}
|
||||
</button>
|
||||
<input
|
||||
v-model="languageDraft"
|
||||
class="content-markdown-code-block-editor__language pointer-events-auto w-[7.5rem] rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs text-white outline-none transition-colors placeholder:text-white/35 focus:border-white/30 focus:bg-white/15"
|
||||
type="text"
|
||||
placeholder="Language..."
|
||||
spellcheck="false"
|
||||
@mousedown.stop
|
||||
@keydown.stop
|
||||
@blur="onLanguageCommit"
|
||||
@keydown.enter.prevent="onLanguageCommit"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ContentMarkdownEditableInline
|
||||
tag="pre"
|
||||
block-class="content-markdown-code-block-editor__editor m-0 min-w-0 border-0 bg-transparent p-0 font-mono text-sm leading-6 text-white outline-none"
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
:source-line="bodySourceLine"
|
||||
:model-value="modelValue"
|
||||
@input="onBodyInput"
|
||||
@commit="onBodyCommit"
|
||||
@insert-below="onExitBelow"
|
||||
/>
|
||||
</ProseCodeBlock>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-markdown-code-block-editor :deep(.prose-code-block__content) {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor :deep(.prose-code-block__header) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor__toolbar {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor__toolbar > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
import {
|
||||
convertEditableHtmlToMarkdown,
|
||||
getRangeInnerHtml,
|
||||
escapeHtml,
|
||||
getEditableCaretOffset,
|
||||
markdownInlineToHtml,
|
||||
markdownMultilineInlineToHtml
|
||||
readEditableTextFromElement,
|
||||
setEditableCaretOffset
|
||||
} from '../../lib/markdown-inline.js'
|
||||
import { parseSlashInput } from '../../lib/markdown-slash-commands.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 인라인 마크다운 원문 */
|
||||
@@ -12,31 +14,32 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 여러 줄(Shift+Enter 줄바꿈) 허용 */
|
||||
multiline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Enter 동작
|
||||
* - split-paragraph: 문단 분리
|
||||
* - insert-below: 블록 아래 새 줄 삽입
|
||||
* - none: 기본(제목 등 단일 줄 blur)
|
||||
* - focus-next: Enter 시 blur 없이 enter-advance 발생
|
||||
* - multiline: Enter 기본 동작(코드·콜아웃 본문 등)
|
||||
*/
|
||||
enterMode: {
|
||||
type: String,
|
||||
default: 'none'
|
||||
},
|
||||
/**
|
||||
* ↑↓ 이동 범위
|
||||
* - document: 렌더러 전체 블록
|
||||
* - parent: 가장 가까운 data-editable-scope 컨테이너 안
|
||||
*/
|
||||
navigationScope: {
|
||||
type: String,
|
||||
default: 'document'
|
||||
},
|
||||
/** @deprecated enterMode 사용 */
|
||||
splitOnEnter: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** Shift+Enter 줄바꿈 허용 */
|
||||
allowHardBreak: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 원본 마크다운 줄 번호(0-based) */
|
||||
sourceLine: {
|
||||
type: Number,
|
||||
@@ -61,10 +64,38 @@ const props = defineProps({
|
||||
allowRawToggle: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 슬래시 메뉴가 열려 키보드 탐색 중인지 */
|
||||
slashMenuActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** ESC 등으로 이 줄의 슬래시 메뉴를 닫은 상태(문자 `/`는 유지) */
|
||||
slashCommandSuppressed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** true면 인라인 마크다운 변환 없이 줄바꿈 유지(코드 블록 등) */
|
||||
plainText: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['commit', 'split', 'insert-below', 'delete-line', 'raw-mode'])
|
||||
const emit = defineEmits([
|
||||
'commit',
|
||||
'input',
|
||||
'split',
|
||||
'insert-below',
|
||||
'delete-line',
|
||||
'merge-with-previous',
|
||||
'raw-mode',
|
||||
'slash-update',
|
||||
'slash-end',
|
||||
'slash-apply',
|
||||
'enter-advance',
|
||||
'leave-block'
|
||||
])
|
||||
|
||||
const rootRef = ref(null)
|
||||
const isFocused = ref(false)
|
||||
@@ -87,9 +118,14 @@ const resolvedEnterMode = computed(() => {
|
||||
* modelValue를 편집용 HTML로 변환한다.
|
||||
* @returns {string} HTML
|
||||
*/
|
||||
const toEditorHtml = () => (props.multiline
|
||||
? markdownMultilineInlineToHtml(props.modelValue)
|
||||
: markdownInlineToHtml(props.modelValue))
|
||||
const toEditorHtml = () => markdownInlineToHtml(props.modelValue.replace(/\n/g, ' '))
|
||||
|
||||
/**
|
||||
* plainText 모드용 편집 HTML을 만든다.
|
||||
* @param {string} value - 본문
|
||||
* @returns {string} HTML
|
||||
*/
|
||||
const plainTextToEditorHtml = (value) => escapeHtml(String(value ?? '')).replace(/\n/g, '<br>')
|
||||
|
||||
/**
|
||||
* 편집 영역 HTML을 동기화한다.
|
||||
@@ -105,15 +141,38 @@ const syncEditorHtml = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.plainText) {
|
||||
rootRef.value.innerHTML = plainTextToEditorHtml(props.modelValue)
|
||||
return
|
||||
}
|
||||
|
||||
rootRef.value.innerHTML = toEditorHtml()
|
||||
}
|
||||
|
||||
watch(() => [props.modelValue, props.rawLine], () => {
|
||||
if (!isFocused.value) {
|
||||
showingRaw.value = false
|
||||
syncEditorHtml()
|
||||
return
|
||||
}
|
||||
|
||||
syncEditorHtml()
|
||||
if (showingRaw.value) {
|
||||
if (props.rawLine && rootRef.value && rootRef.value.textContent !== props.rawLine) {
|
||||
rootRef.value.textContent = props.rawLine
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const current = readEditorValue()
|
||||
|
||||
if (current !== props.modelValue) {
|
||||
if (props.plainText) {
|
||||
rootRef.value.innerHTML = plainTextToEditorHtml(props.modelValue)
|
||||
} else {
|
||||
rootRef.value.innerHTML = toEditorHtml()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -126,6 +185,43 @@ onMounted(() => {
|
||||
*/
|
||||
const onFocus = () => {
|
||||
isFocused.value = true
|
||||
syncSlashState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 명령 입력 상태를 부모에 알린다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncSlashState = () => {
|
||||
if (props.sourceLine === null || showingRaw.value) {
|
||||
emit('slash-end')
|
||||
return
|
||||
}
|
||||
|
||||
if (props.slashCommandSuppressed) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = parseSlashInput(readEditorValue())
|
||||
|
||||
if (!parsed) {
|
||||
emit('slash-end', { sourceLine: props.sourceLine })
|
||||
return
|
||||
}
|
||||
|
||||
emit('slash-update', {
|
||||
sourceLine: props.sourceLine,
|
||||
query: parsed.query,
|
||||
value: parsed.raw
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 시 슬래시 상태를 갱신한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onEditorInput = () => {
|
||||
syncSlashState()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,7 +241,7 @@ const readEditorValue = () => {
|
||||
return rootRef.value.textContent ?? ''
|
||||
}
|
||||
|
||||
return convertEditableHtmlToMarkdown(rootRef.value.innerHTML)
|
||||
return readEditableTextFromElement(rootRef.value)
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
@@ -265,6 +361,30 @@ const getRawPrefixLength = () => {
|
||||
return match ? match[1].length : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 영역에 접힙지 않은 텍스트 선택이 있는지 확인한다.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const hasNonCollapsedSelection = () => {
|
||||
if (!import.meta.client || !rootRef.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (!rootRef.value.contains(range.commonAncestorContainer)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !range.collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서가 줄의 논리적 맨 앞인지 확인한다(원문 접두사 직후 포함).
|
||||
* @returns {boolean}
|
||||
@@ -290,25 +410,109 @@ const isCaretAtLogicalStart = () => {
|
||||
const isCaretAtLogicalEnd = () => isCaretAtEdge('end')
|
||||
|
||||
/**
|
||||
* 이전·다음 편집 줄로 포커스를 옮긴다.
|
||||
* @param {number} direction - -1 이전 줄, 1 다음 줄
|
||||
* @param {boolean} cursorAtStart - 커서를 줄 앞에 둘지
|
||||
* 커서가 위치한 시각 줄 정보를 반환한다.
|
||||
* @returns {{ column: number, lineIndex: number, lines: string[], isFirstLine: boolean, isLastLine: boolean }}
|
||||
*/
|
||||
const getCaretLineContext = () => {
|
||||
const fallback = {
|
||||
column: 0,
|
||||
lineIndex: 0,
|
||||
lines: [''],
|
||||
isFirstLine: true,
|
||||
isLastLine: true
|
||||
}
|
||||
|
||||
if (!import.meta.client || !rootRef.value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (!rootRef.value.contains(range.commonAncestorContainer)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const full = readEditorValue()
|
||||
const offset = getEditableCaretOffset(rootRef.value, range)
|
||||
const lines = full.length ? full.split('\n') : ['']
|
||||
const before = full.slice(0, offset)
|
||||
const lineIndex = before.split('\n').length - 1
|
||||
const lineStart = before.lastIndexOf('\n') + 1
|
||||
const column = offset - lineStart
|
||||
|
||||
return {
|
||||
column,
|
||||
lineIndex,
|
||||
lines,
|
||||
isFirstLine: lineIndex === 0,
|
||||
isLastLine: lineIndex >= lines.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 줄 배열에서 지정 줄·열의 오프셋을 계산한다.
|
||||
* @param {string[]} lines - 줄 목록
|
||||
* @param {number} lineIndex - 줄 인덱스
|
||||
* @param {number} column - 열
|
||||
* @returns {number} 오프셋
|
||||
*/
|
||||
const getOffsetForLineColumn = (lines, lineIndex, column) => {
|
||||
let offset = 0
|
||||
|
||||
for (let index = 0; index < lineIndex; index += 1) {
|
||||
offset += (lines[index] ?? '').length + 1
|
||||
}
|
||||
|
||||
const line = lines[lineIndex] ?? ''
|
||||
|
||||
return offset + Math.min(Math.max(column, 0), line.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인접 편집 블록으로 포커스를 옮긴다.
|
||||
* @param {number} direction - -1 이전, 1 다음
|
||||
* @param {number} column - 유지할 열(column 모드)
|
||||
* @param {'column'|'block-start'|'block-end'} [caretMode='column'] - 커서 배치
|
||||
* @returns {void}
|
||||
*/
|
||||
const navigateToAdjacentLine = (direction, cursorAtStart) => {
|
||||
/**
|
||||
* ↑↓ 이동 대상 편집 요소 목록을 반환한다.
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
const getNavigationElements = () => {
|
||||
if (!import.meta.client || !rootRef.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const root = props.navigationScope === 'parent'
|
||||
? rootRef.value.closest('[data-editable-scope]')
|
||||
: rootRef.value.closest('.content-markdown-renderer')
|
||||
|
||||
if (!root) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [...root.querySelectorAll('[data-source-line]')]
|
||||
.sort((left, right) => Number(left.dataset.sourceLine) - Number(right.dataset.sourceLine))
|
||||
}
|
||||
|
||||
const navigateToAdjacentBlock = (direction, column, caretMode = 'column') => {
|
||||
if (!import.meta.client || props.sourceLine === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = rootRef.value?.closest('.content-markdown-renderer')
|
||||
const elements = getNavigationElements()
|
||||
|
||||
if (!container) {
|
||||
if (!elements.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const elements = [...container.querySelectorAll('[data-source-line]')]
|
||||
.sort((left, right) => Number(left.dataset.sourceLine) - Number(right.dataset.sourceLine))
|
||||
|
||||
const currentIndex = elements.findIndex(
|
||||
(element) => Number(element.dataset.sourceLine) === props.sourceLine
|
||||
)
|
||||
@@ -320,16 +524,113 @@ const navigateToAdjacentLine = (direction, cursorAtStart) => {
|
||||
const target = elements[currentIndex + direction]
|
||||
|
||||
if (!target) {
|
||||
if (resolvedEnterMode.value === 'multiline' && direction === 1) {
|
||||
emit('insert-below', buildInsertBelowPayload())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target.focus()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(target)
|
||||
range.collapse(cursorAtStart)
|
||||
if (props.navigationScope === 'parent') {
|
||||
emit('leave-block', { direction })
|
||||
}
|
||||
|
||||
target.focus({ preventScroll: true })
|
||||
|
||||
const scopedCaretMode = props.navigationScope === 'parent'
|
||||
? (direction === 1 ? 'block-start' : 'block-end')
|
||||
: caretMode
|
||||
|
||||
const targetText = readEditableTextFromElement(target)
|
||||
const targetLines = targetText.length ? targetText.split('\n') : ['']
|
||||
let targetLineIndex = direction === -1 ? targetLines.length - 1 : 0
|
||||
let targetColumn = column
|
||||
|
||||
if (scopedCaretMode === 'block-start') {
|
||||
targetLineIndex = 0
|
||||
targetColumn = 0
|
||||
} else if (scopedCaretMode === 'block-end') {
|
||||
targetLineIndex = targetLines.length - 1
|
||||
targetColumn = (targetLines[targetLineIndex] ?? '').length
|
||||
} else {
|
||||
targetColumn = Math.min(column, (targetLines[targetLineIndex] ?? '').length)
|
||||
}
|
||||
|
||||
const targetOffset = getOffsetForLineColumn(targetLines, targetLineIndex, targetColumn)
|
||||
|
||||
setEditableCaretOffset(target, targetOffset)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 블록의 맨 앞·맨 끝으로 커서를 옮긴다.
|
||||
* @param {'start'|'end'} edge - 위치
|
||||
* @returns {void}
|
||||
*/
|
||||
const setCaretAtBlockEdge = (edge) => {
|
||||
if (!rootRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (edge === 'start') {
|
||||
if (showingRaw.value) {
|
||||
placeCursorAfterPrefix()
|
||||
return
|
||||
}
|
||||
|
||||
setEditableCaretOffset(rootRef.value, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const full = readEditorValue()
|
||||
|
||||
setEditableCaretOffset(rootRef.value, full.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 기준 앞·뒤 텍스트를 읽는다.
|
||||
* @returns {{ before: string, after: string }}
|
||||
*/
|
||||
const readCaretSplit = () => {
|
||||
if (!import.meta.client || !rootRef.value) {
|
||||
return { before: props.modelValue, after: '' }
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return { before: props.modelValue, after: '' }
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (!rootRef.value.contains(range.commonAncestorContainer)) {
|
||||
return { before: props.modelValue, after: '' }
|
||||
}
|
||||
|
||||
const full = readEditorValue()
|
||||
const offset = getEditableCaretOffset(rootRef.value, range)
|
||||
|
||||
return {
|
||||
before: full.slice(0, offset),
|
||||
after: full.slice(offset)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* insert-below 이벤트 페이로드를 만든다.
|
||||
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
|
||||
*/
|
||||
const buildInsertBelowPayload = () => {
|
||||
const { before, after } = readCaretSplit()
|
||||
|
||||
return {
|
||||
value: readEditorValue(),
|
||||
raw: showingRaw.value,
|
||||
before,
|
||||
after,
|
||||
caretAtStart: isCaretAtLogicalStart(),
|
||||
caretAtEnd: isCaretAtLogicalEnd()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,40 +638,7 @@ const navigateToAdjacentLine = (direction, cursorAtStart) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const splitAtCaret = () => {
|
||||
if (!import.meta.client || !rootRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
emit('split', { before: props.modelValue, after: '' })
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (!rootRef.value.contains(range.commonAncestorContainer)) {
|
||||
return
|
||||
}
|
||||
|
||||
const beforeRange = document.createRange()
|
||||
beforeRange.selectNodeContents(rootRef.value)
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
const afterRange = document.createRange()
|
||||
afterRange.setStart(range.endContainer, range.endOffset)
|
||||
|
||||
if (rootRef.value.lastChild) {
|
||||
afterRange.setEndAfter(rootRef.value.lastChild)
|
||||
} else {
|
||||
afterRange.setEnd(rootRef.value, 0)
|
||||
}
|
||||
|
||||
const before = convertEditableHtmlToMarkdown(getRangeInnerHtml(beforeRange))
|
||||
const after = convertEditableHtmlToMarkdown(getRangeInnerHtml(afterRange))
|
||||
|
||||
emit('split', { before, after })
|
||||
emit('split', readCaretSplit())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,15 +657,14 @@ const scheduleEnterAction = (action) => {
|
||||
if (action === 'split') {
|
||||
splitAtCaret()
|
||||
} else {
|
||||
const nextValue = readEditorValue()
|
||||
emit('insert-below', showingRaw.value ? { value: nextValue, raw: true } : nextValue)
|
||||
emit('insert-below', buildInsertBelowPayload())
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
splitLock.value = false
|
||||
window.setTimeout(() => {
|
||||
suppressBlurCommit.value = false
|
||||
}, 50)
|
||||
}, 180)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -477,7 +744,22 @@ const onKeydown = (event) => {
|
||||
|
||||
if (
|
||||
event.key === 'Backspace'
|
||||
&& isCaretAtEdge('start')
|
||||
&& !hasNonCollapsedSelection()
|
||||
&& isCaretAtLogicalStart()
|
||||
&& !showingRaw.value
|
||||
&& props.sourceLine !== null
|
||||
&& readEditorValue().trim()
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('merge-with-previous', buildInsertBelowPayload())
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Backspace'
|
||||
&& !hasNonCollapsedSelection()
|
||||
&& isCaretAtLogicalStart()
|
||||
&& !showingRaw.value
|
||||
&& props.sourceLine !== null
|
||||
&& !readEditorValue().trim()
|
||||
@@ -490,6 +772,7 @@ const onKeydown = (event) => {
|
||||
|
||||
if (
|
||||
event.key === 'Backspace'
|
||||
&& !hasNonCollapsedSelection()
|
||||
&& props.allowRawToggle
|
||||
&& props.rawLine
|
||||
&& !showingRaw.value
|
||||
@@ -510,37 +793,80 @@ const onKeydown = (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' && isCaretAtLogicalStart()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
navigateToAdjacentLine(-1, false)
|
||||
const hasCommandModifier = event.metaKey || event.ctrlKey
|
||||
|
||||
if (hasCommandModifier && !event.shiftKey && !event.altKey) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setCaretAtBlockEdge('start')
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setCaretAtBlockEdge('end')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (props.slashMenuActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' && isCaretAtLogicalEnd()) {
|
||||
if (!hasCommandModifier && !event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
const lineContext = getCaretLineContext()
|
||||
const direction = event.key === 'ArrowUp' ? -1 : 1
|
||||
const isMultilineEditor = resolvedEnterMode.value === 'multiline'
|
||||
|
||||
if (isMultilineEditor) {
|
||||
if (direction === -1 && !lineContext.isFirstLine) {
|
||||
return
|
||||
}
|
||||
|
||||
if (direction === 1 && !lineContext.isLastLine) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
navigateToAdjacentLine(1, true)
|
||||
navigateToAdjacentBlock(direction, lineContext.column)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
|
||||
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
navigateToAdjacentLine(-1, false)
|
||||
navigateToAdjacentBlock(-1, 0, 'block-end')
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
|
||||
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
navigateToAdjacentLine(1, true)
|
||||
navigateToAdjacentBlock(1, 0, 'block-start')
|
||||
return
|
||||
}
|
||||
|
||||
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('slash-apply', {
|
||||
sourceLine: props.sourceLine,
|
||||
value: readEditorValue()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -554,11 +880,19 @@ const onKeydown = (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && event.shiftKey && props.allowHardBreak) {
|
||||
if (event.key === 'Enter' && enterMode === 'focus-next') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('enter-advance')
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !props.multiline && enterMode === 'none') {
|
||||
if (event.key === 'Enter' && enterMode === 'none') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
rootRef.value?.blur()
|
||||
@@ -596,16 +930,13 @@ const focusEditor = (position = 'end') => {
|
||||
return
|
||||
}
|
||||
|
||||
rootRef.value.focus()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(rootRef.value)
|
||||
range.collapse(position === 'start')
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
rootRef.value.focus({ preventScroll: true })
|
||||
const textLength = readEditorValue().length
|
||||
const offset = position === 'start' ? 0 : textLength
|
||||
setEditableCaretOffset(rootRef.value, offset)
|
||||
}
|
||||
|
||||
defineExpose({ focusEditor })
|
||||
defineExpose({ focusEditor, readEditorValue })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -623,6 +954,7 @@ defineExpose({ focusEditor })
|
||||
spellcheck="true"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onEditorInput"
|
||||
@paste="onPaste"
|
||||
@keydown="onKeydown"
|
||||
@compositionend="onCompositionEnd"
|
||||
@@ -630,6 +962,10 @@ defineExpose({ focusEditor })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-markdown-editable-inline {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.content-markdown-editable-inline--idle {
|
||||
cursor: text;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -4,8 +4,11 @@ import {
|
||||
getImageDisplayCaption,
|
||||
parseImageMarkdownLine
|
||||
} from '../../lib/markdown-image.js'
|
||||
import { paragraphTextToSourceLines, parseInlineSegments } from '../../lib/markdown-inline.js'
|
||||
import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
|
||||
import {
|
||||
appendTextToMarkdownLine,
|
||||
getAppendTextForMerge,
|
||||
getMergeJunctionDisplayOffset,
|
||||
hasListMarker,
|
||||
hasQuoteMarker,
|
||||
isEmptyListMarkerLine,
|
||||
@@ -14,6 +17,13 @@ import {
|
||||
stripListMarker,
|
||||
stripQuoteMarker
|
||||
} from '../../lib/markdown-live-edit.js'
|
||||
import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
|
||||
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
|
||||
import { parseCalloutOptions } from '../../lib/markdown-callout.js'
|
||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
|
||||
import ContentMarkdownToggleEditor from './ContentMarkdownToggleEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
@@ -24,10 +34,30 @@ const props = defineProps({
|
||||
interactive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 슬래시 명령 메뉴 키보드 탐색 중 */
|
||||
slashMenuActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** ESC로 슬래시 메뉴를 닫은 원본 줄 번호(0-based) */
|
||||
slashSuppressedLines: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['gallery-reorder', 'block-content-change', 'append-paragraph', 'insert-after-line', 'delete-line'])
|
||||
const emit = defineEmits([
|
||||
'gallery-reorder',
|
||||
'block-content-change',
|
||||
'append-paragraph',
|
||||
'insert-after-line',
|
||||
'delete-line',
|
||||
'merge-with-previous-line',
|
||||
'slash-update',
|
||||
'slash-end',
|
||||
'slash-apply'
|
||||
])
|
||||
|
||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
|
||||
@@ -41,6 +71,8 @@ const galleryDropTarget = ref(null)
|
||||
const pendingFocusLine = ref(null)
|
||||
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
|
||||
const pendingFocusPosition = ref('auto')
|
||||
/** @type {import('vue').Ref<number|null>} 포커스 후 텍스트 오프셋 */
|
||||
const pendingFocusOffset = ref(null)
|
||||
const rendererRootRef = ref(null)
|
||||
/** @type {import('vue').Ref<Set<number>>} 원문(raw) 편집 중인 목록 줄 */
|
||||
const rawEditingSourceLines = ref(new Set())
|
||||
@@ -91,51 +123,11 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
|
||||
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
|
||||
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
|
||||
calloutEmoji: options.calloutEmoji || '💡',
|
||||
calloutBackground: options.calloutBackground || 'blue'
|
||||
calloutBackground: options.calloutBackground || 'blue',
|
||||
codeLanguage: options.codeLanguage || '',
|
||||
codeShowLineNumbers: options.codeShowLineNumbers !== false
|
||||
})
|
||||
|
||||
const calloutBackgroundOptions = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
|
||||
|
||||
/**
|
||||
* 콜아웃 선언부 옵션을 파싱
|
||||
* @param {string} line - 콜아웃 선언 라인
|
||||
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
|
||||
*/
|
||||
const parseCalloutOptions = (line) => {
|
||||
const options = {
|
||||
calloutEmojiEnabled: true,
|
||||
calloutEmoji: '💡',
|
||||
calloutBackground: 'blue'
|
||||
}
|
||||
const tokens = line.trim().split(/\s+/).slice(1)
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const [rawKey, ...rawValueParts] = token.split('=')
|
||||
if (!rawKey || !rawValueParts.length) {
|
||||
return
|
||||
}
|
||||
const key = rawKey.toLowerCase()
|
||||
const value = rawValueParts.join('=').trim()
|
||||
|
||||
if (key === 'emoji') {
|
||||
if (!value || value === 'none') {
|
||||
options.calloutEmojiEnabled = false
|
||||
options.calloutEmoji = '💡'
|
||||
} else {
|
||||
options.calloutEmojiEnabled = true
|
||||
options.calloutEmoji = value
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'bg' && calloutBackgroundOptions.includes(value)) {
|
||||
options.calloutBackground = value
|
||||
}
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 이미지 데이터로 변환
|
||||
* @param {string} line - 마크다운 행
|
||||
@@ -143,6 +135,26 @@ const parseCalloutOptions = (line) => {
|
||||
*/
|
||||
const parseImageLine = (line) => parseImageMarkdownLine(line)
|
||||
|
||||
/**
|
||||
* 인용 마커 줄인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isQuoteMarkerLine = (line) => {
|
||||
const trimmed = String(line ?? '').trim()
|
||||
return trimmed === '>' || /^>\s/.test(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 불릿 목록 마커 줄인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isBulletListMarkerLine = (line) => {
|
||||
const trimmed = String(line ?? '').trim()
|
||||
return trimmed === '-' || /^-\s/.test(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
@@ -169,14 +181,7 @@ const isMarkdownBlockStart = (line) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 hard break 표식이 있는 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean} hard break 여부
|
||||
*/
|
||||
const hasMarkdownHardBreak = (line) => /( {2,}|\\)$/.test(line)
|
||||
|
||||
/**
|
||||
* 문단 행에서 hard break 표식을 제거한다.
|
||||
* 문단 행에서 hard break 표식(레거시)을 제거한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {string} 정리된 문단 행
|
||||
*/
|
||||
@@ -444,6 +449,7 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const startLine = index
|
||||
const fenceOptions = parseCodeFenceLine(trimmedLine) || { language: '', showLineNumbers: true }
|
||||
const codeLines = []
|
||||
index += 1
|
||||
|
||||
@@ -453,7 +459,10 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
}
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`),
|
||||
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, {
|
||||
codeLanguage: fenceOptions.language,
|
||||
codeShowLineNumbers: fenceOptions.showLineNumbers
|
||||
}),
|
||||
startLine,
|
||||
index
|
||||
))
|
||||
@@ -481,11 +490,11 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('> ')) {
|
||||
if (isQuoteMarkerLine(line)) {
|
||||
const startLine = index
|
||||
const quoteLines = []
|
||||
|
||||
while (index < lines.length && lines[index].trim().startsWith('>')) {
|
||||
while (index < lines.length && isQuoteMarkerLine(lines[index])) {
|
||||
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
|
||||
index += 1
|
||||
}
|
||||
@@ -498,12 +507,12 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^- /.test(trimmedLine)) {
|
||||
if (isBulletListMarkerLine(line)) {
|
||||
const startLine = index
|
||||
const items = []
|
||||
|
||||
while (index < lines.length && /^- /.test(lines[index].trim())) {
|
||||
items.push(lines[index].trim().replace(/^- /, ''))
|
||||
while (index < lines.length && isBulletListMarkerLine(lines[index])) {
|
||||
items.push(lines[index].trim().replace(/^-\s?/, ''))
|
||||
index += 1
|
||||
}
|
||||
|
||||
@@ -531,29 +540,12 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
const paragraphStartLine = index
|
||||
const paragraphLines = [cleanParagraphLine(line)]
|
||||
let shouldJoinNextLine = hasMarkdownHardBreak(line)
|
||||
index += 1
|
||||
|
||||
while (shouldJoinNextLine && index < lines.length) {
|
||||
const nextLine = lines[index]
|
||||
const nextTrimmedLine = nextLine.trim()
|
||||
|
||||
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
|
||||
break
|
||||
}
|
||||
|
||||
paragraphLines.push(cleanParagraphLine(nextLine))
|
||||
shouldJoinNextLine = hasMarkdownHardBreak(nextLine)
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`),
|
||||
paragraphStartLine,
|
||||
index - 1
|
||||
createBlock('paragraph', cleanParagraphLine(line), null, `block-${blocks.length}`),
|
||||
index,
|
||||
index
|
||||
))
|
||||
index += 1
|
||||
}
|
||||
|
||||
return blocks
|
||||
@@ -569,12 +561,14 @@ watch(() => props.content, () => {
|
||||
|
||||
const line = pendingFocusLine.value
|
||||
const position = pendingFocusPosition.value
|
||||
const offset = pendingFocusOffset.value
|
||||
pendingFocusLine.value = null
|
||||
pendingFocusPosition.value = 'auto'
|
||||
pendingFocusOffset.value = null
|
||||
|
||||
nextTick(() => {
|
||||
nextTick(() => {
|
||||
focusEditableAtLine(line, 0, position)
|
||||
focusEditableAtLine(line, 0, position, offset)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -584,9 +578,10 @@ watch(() => props.content, () => {
|
||||
* @param {number} lineIndex - 줄 번호(0-based)
|
||||
* @param {number} [attempt=0] - DOM 탐색 재시도 횟수
|
||||
* @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치
|
||||
* @param {number|null} [caretOffset=null] - 텍스트 오프셋
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') => {
|
||||
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null) => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
@@ -596,7 +591,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') =>
|
||||
if (!element) {
|
||||
if (attempt < 8) {
|
||||
requestAnimationFrame(() => {
|
||||
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition)
|
||||
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -611,17 +606,31 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') =>
|
||||
element.innerHTML = ''
|
||||
}
|
||||
|
||||
element.focus()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
const collapseToStart = cursorPosition === 'start'
|
||||
|| (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))
|
||||
range.collapse(collapseToStart)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
element.focus({ preventScroll: true })
|
||||
|
||||
if (typeof caretOffset === 'number' && caretOffset >= 0) {
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
|
||||
return
|
||||
}
|
||||
|
||||
if (cursorPosition === 'start' || (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))) {
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (cursorPosition === 'end') {
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), text.length)
|
||||
return
|
||||
}
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusEditableAtLine
|
||||
})
|
||||
|
||||
/**
|
||||
* 인라인 편집 원문 모드 표시 상태를 갱신한다.
|
||||
* @param {{ sourceLine: number, active: boolean }} payload - 줄 번호·활성 여부
|
||||
@@ -690,6 +699,33 @@ const normalizeCommitPayload = (payload) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* insert-below 이벤트 페이로드를 정규화한다.
|
||||
* @param {string|Object} payload - 페이로드
|
||||
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
|
||||
*/
|
||||
const normalizeInsertBelowPayload = (payload) => {
|
||||
if (typeof payload === 'string') {
|
||||
return {
|
||||
value: payload,
|
||||
raw: false,
|
||||
before: '',
|
||||
after: '',
|
||||
caretAtStart: false,
|
||||
caretAtEnd: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: String(payload?.value ?? ''),
|
||||
raw: payload?.raw === true,
|
||||
before: String(payload?.before ?? ''),
|
||||
after: String(payload?.after ?? ''),
|
||||
caretAtStart: payload?.caretAtStart === true,
|
||||
caretAtEnd: payload?.caretAtEnd === true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 마크다운 줄을 반환한다.
|
||||
* @param {number} lineIndex - 줄 번호
|
||||
@@ -770,6 +806,20 @@ const formatListLine = (value, raw, block, itemIndex) => {
|
||||
return clean ? `- ${clean}` : '- '
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 목록 마커 줄을 만든다.
|
||||
* @param {Object} block - 목록 블록
|
||||
* @param {number} itemIndex - 항목 인덱스
|
||||
* @returns {string} 마크다운 줄
|
||||
*/
|
||||
const createEmptyListMarkerLine = (block, itemIndex) => {
|
||||
if (block.ordered) {
|
||||
return `${getListMarkerNumber(block, itemIndex)}. `
|
||||
}
|
||||
|
||||
return '- '
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록을 줄 단위로 분리한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
@@ -790,6 +840,18 @@ const getQuoteLines = (block) => {
|
||||
return fromText.slice(0, lineCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 블록 줄 번호 목록을 만든다.
|
||||
* @param {Object} block - 코드 블록
|
||||
* @returns {number[]} 줄 번호(1부터)
|
||||
*/
|
||||
const getCodeLineNumbers = (block) => {
|
||||
const text = String(block.text ?? '')
|
||||
const lineCount = text.length ? text.split('\n').length : 1
|
||||
|
||||
return Array.from({ length: lineCount }, (_, index) => index + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인라인 편집 결과를 마크다운 줄로 반영한다.
|
||||
* @param {Object} block - 블록
|
||||
@@ -814,15 +876,81 @@ const commitInlineBlockLines = (block, replacementLines) => {
|
||||
* @param {string} text - 편집된 텍스트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onParagraphInlineCommit = (block, text) => {
|
||||
const value = String(text ?? '')
|
||||
/**
|
||||
* 코드 블록 편집 반영
|
||||
* @param {Object} block - 코드 블록
|
||||
* @param {string[]} replacementLines - 마크다운 줄
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCodeBlockCommit = (block, replacementLines) => {
|
||||
commitInlineBlockLines(block, replacementLines)
|
||||
}
|
||||
|
||||
if (value.includes('\n')) {
|
||||
commitInlineBlockLines(block, paragraphTextToSourceLines(value))
|
||||
/**
|
||||
* 코드 블록 마지막 줄에서 아래로 이탈 — 본문 저장 후 다음 문단 삽입
|
||||
* @param {Object} block - 코드 블록
|
||||
* @param {string|Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCodeBlockInsertBelow = (block, payload) => {
|
||||
const { value } = normalizeInsertBelowPayload(payload)
|
||||
|
||||
onCodeBlockCommit(block, buildCodeBlockLines({
|
||||
language: block.codeLanguage,
|
||||
showLineNumbers: block.codeShowLineNumbers,
|
||||
body: value
|
||||
}))
|
||||
onInsertBelowBlock(block, { lines: [''] })
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜아웃 편집 반영
|
||||
* @param {Object} block - 콜아웃 블록
|
||||
* @param {string[]} replacementLines - 마크다운 줄
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCalloutBlockCommit = (block, replacementLines) => {
|
||||
commitInlineBlockLines(block, replacementLines)
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 편집 반영
|
||||
* @param {Object} block - 토글 블록
|
||||
* @param {string[]} replacementLines - 마크다운 줄
|
||||
* @returns {void}
|
||||
*/
|
||||
const onToggleBlockCommit = (block, replacementLines) => {
|
||||
commitInlineBlockLines(block, replacementLines)
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 본문 마지막 줄에서 아래로 이탈
|
||||
* @param {Object} block - 토글 블록
|
||||
* @param {string|Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onToggleBlockInsertBelow = (block, payload) => {
|
||||
const { value } = normalizeInsertBelowPayload(payload)
|
||||
|
||||
onToggleBlockCommit(block, buildToggleBlockLines({
|
||||
title: block.title,
|
||||
body: value
|
||||
}))
|
||||
pendingFocusPosition.value = 'start'
|
||||
pendingFocusOffset.value = 0
|
||||
onInsertBelowBlock(block, { lines: [''] })
|
||||
}
|
||||
|
||||
const onParagraphInlineCommit = (block, text) => {
|
||||
const value = String(text ?? '').replace(/\r/g, '')
|
||||
const lines = value.split('\n').map((line) => line.trimEnd())
|
||||
|
||||
if (lines.length <= 1) {
|
||||
commitInlineBlockLines(block, [lines[0] ?? ''])
|
||||
return
|
||||
}
|
||||
|
||||
commitInlineBlockLines(block, [value])
|
||||
commitInlineBlockLines(block, lines.filter((line) => line.length > 0))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -839,6 +967,27 @@ const onSpacerInlineCommit = (block, text) => {
|
||||
commitInlineBlockLines(block, [text])
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 Enter 분리 결과 줄 배열을 만든다.
|
||||
* @param {string} head - 커서 앞 텍스트
|
||||
* @param {string} tail - 커서 뒤 텍스트
|
||||
* @returns {string[]} 분리된 줄
|
||||
*/
|
||||
const buildParagraphSplitLines = (head, tail) => {
|
||||
const h = String(head ?? '').replace(/\n/g, ' ')
|
||||
const t = String(tail ?? '').replace(/\n/g, ' ')
|
||||
|
||||
if (!t.length) {
|
||||
return [h, '']
|
||||
}
|
||||
|
||||
if (!h.length) {
|
||||
return ['', t]
|
||||
}
|
||||
|
||||
return [h, t]
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 Enter 분리 — 마크다운에 빈 줄을 넣어 다음 문단을 만든다.
|
||||
* @param {Object} block - 블록
|
||||
@@ -854,23 +1003,11 @@ const onParagraphSplit = (block, { before, after }) => {
|
||||
|
||||
lastParagraphSplitAt = now
|
||||
|
||||
const head = String(before ?? '')
|
||||
const tail = String(after ?? '')
|
||||
let replacementLines = []
|
||||
let focusLine = block.meta.startLine
|
||||
|
||||
if (!tail.length) {
|
||||
replacementLines = [head, '']
|
||||
focusLine = block.meta.startLine + 1
|
||||
} else if (!head.length) {
|
||||
replacementLines = ['', tail]
|
||||
focusLine = block.meta.startLine + 1
|
||||
} else {
|
||||
replacementLines = [head, '', tail]
|
||||
focusLine = block.meta.startLine + 2
|
||||
}
|
||||
const replacementLines = buildParagraphSplitLines(before, after)
|
||||
const focusLine = block.meta.startLine + Math.max(replacementLines.length - 1, 1)
|
||||
|
||||
pendingFocusLine.value = focusLine
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, replacementLines)
|
||||
}
|
||||
|
||||
@@ -900,9 +1037,27 @@ const onInsertBelowBlock = (block, options = {}) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onListItemInsertBelow = (block, itemIndex, payload) => {
|
||||
const { value, raw } = normalizeCommitPayload(payload)
|
||||
const { value, raw, before, after, caretAtStart, caretAtEnd } = normalizeInsertBelowPayload(payload)
|
||||
const nextLines = getBlockSourceLines(block)
|
||||
|
||||
if (caretAtStart && after.length) {
|
||||
nextLines[itemIndex] = formatListLine(after, raw, block, itemIndex)
|
||||
nextLines.splice(itemIndex, 0, createEmptyListMarkerLine(block, itemIndex))
|
||||
pendingFocusLine.value = block.meta.startLine + itemIndex
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
return
|
||||
}
|
||||
|
||||
if (before.length && after.length) {
|
||||
nextLines[itemIndex] = formatListLine(before, raw, block, itemIndex)
|
||||
nextLines.splice(itemIndex + 1, 0, formatListLine(after, raw, block, itemIndex + 1))
|
||||
pendingFocusLine.value = block.meta.startLine + itemIndex + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
return
|
||||
}
|
||||
|
||||
if (itemIndex < nextLines.length) {
|
||||
nextLines[itemIndex] = formatListLine(value, raw, block, itemIndex)
|
||||
}
|
||||
@@ -917,10 +1072,13 @@ const onListItemInsertBelow = (block, itemIndex, payload) => {
|
||||
return
|
||||
}
|
||||
|
||||
nextLines.splice(itemIndex + 1, 0, '')
|
||||
pendingFocusLine.value = block.meta.startLine + itemIndex + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
|
||||
if (caretAtEnd || !before.length) {
|
||||
pendingFocusLine.value = (block.meta.endLine ?? block.meta.startLine) + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
onInsertBelowBlock(block, { lines: [''] })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -991,9 +1149,27 @@ const onQuoteLineInlineCommit = (block, lineIndex, payload) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteLineInsertBelow = (block, lineIndex, payload) => {
|
||||
const { value, raw } = normalizeCommitPayload(payload)
|
||||
const { value, raw, before, after, caretAtStart } = normalizeInsertBelowPayload(payload)
|
||||
const nextLines = getBlockSourceLines(block)
|
||||
|
||||
if (caretAtStart && after.length) {
|
||||
nextLines[lineIndex] = formatQuoteLine(after, raw)
|
||||
nextLines.splice(lineIndex, 0, '> ')
|
||||
pendingFocusLine.value = block.meta.startLine + lineIndex
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
return
|
||||
}
|
||||
|
||||
if (before.length && after.length) {
|
||||
nextLines[lineIndex] = formatQuoteLine(before, raw)
|
||||
nextLines.splice(lineIndex + 1, 0, formatQuoteLine(after, raw))
|
||||
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
return
|
||||
}
|
||||
|
||||
if (lineIndex < nextLines.length) {
|
||||
nextLines[lineIndex] = formatQuoteLine(value, raw)
|
||||
}
|
||||
@@ -1056,6 +1232,35 @@ const onDeleteLine = (lineIndex) => {
|
||||
emit('delete-line', lineIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 줄을 이전 줄 끝에 병합한다.
|
||||
* @param {number} lineIndex - 줄 번호
|
||||
* @param {string|Object} payload - 편집 내용
|
||||
* @returns {void}
|
||||
*/
|
||||
const onMergeWithPreviousLine = (lineIndex, payload) => {
|
||||
if (typeof lineIndex !== 'number' || lineIndex <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const { value, raw } = normalizeCommitPayload(payload)
|
||||
const lines = String(props.content || '').split('\n')
|
||||
const prevLine = lines[lineIndex - 1] ?? ''
|
||||
const appendText = getAppendTextForMerge(value, prevLine, raw)
|
||||
|
||||
if (!appendText) {
|
||||
onDeleteLine(lineIndex)
|
||||
return
|
||||
}
|
||||
|
||||
const mergedLine = appendTextToMarkdownLine(prevLine, appendText)
|
||||
|
||||
pendingFocusLine.value = lineIndex - 1
|
||||
pendingFocusPosition.value = 'auto'
|
||||
pendingFocusOffset.value = getMergeJunctionDisplayOffset(prevLine, raw)
|
||||
emit('merge-with-previous-line', { lineIndex, mergedLine })
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
@@ -1267,12 +1472,17 @@ onBeforeUnmount(() => {
|
||||
tag="p"
|
||||
block-class="content-markdown-renderer__paragraph content-markdown-renderer__spacer-line text-base text-[var(--site-text)]"
|
||||
enter-mode="split-paragraph"
|
||||
allow-hard-break
|
||||
:slash-menu-active="slashMenuActive"
|
||||
:slash-command-suppressed="slashSuppressedLines.includes(block.meta.startLine)"
|
||||
:source-line="block.meta.startLine"
|
||||
:model-value="''"
|
||||
@commit="onSpacerInlineCommit(block, $event)"
|
||||
@split="onParagraphSplit(block, $event)"
|
||||
@delete-line="onDeleteLine"
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
||||
@slash-update="emit('slash-update', $event)"
|
||||
@slash-end="emit('slash-end', $event)"
|
||||
@slash-apply="emit('slash-apply', $event)"
|
||||
/>
|
||||
<div v-else-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
|
||||
<ContentMarkdownEditableInline
|
||||
@@ -1287,6 +1497,7 @@ onBeforeUnmount(() => {
|
||||
@commit="onHeadingInlineCommit(block, $event)"
|
||||
@insert-below="onInsertBelowBlock(block)"
|
||||
@delete-line="onDeleteLine"
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
||||
/>
|
||||
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||
@@ -1313,6 +1524,7 @@ onBeforeUnmount(() => {
|
||||
@commit="onQuoteLineInlineCommit(block, quoteLineIndex, $event)"
|
||||
@insert-below="onQuoteLineInsertBelow(block, quoteLineIndex, $event)"
|
||||
@delete-line="onDeleteLine"
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + quoteLineIndex, $event)"
|
||||
@raw-mode="onInlineRawMode"
|
||||
/>
|
||||
</ProseBlockquote>
|
||||
@@ -1352,6 +1564,7 @@ onBeforeUnmount(() => {
|
||||
@commit="onListItemInlineCommit(block, itemIndex, $event)"
|
||||
@insert-below="onListItemInsertBelow(block, itemIndex, $event)"
|
||||
@delete-line="onDeleteLine"
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + itemIndex, $event)"
|
||||
@raw-mode="onInlineRawMode"
|
||||
/>
|
||||
</li>
|
||||
@@ -1390,6 +1603,15 @@ onBeforeUnmount(() => {
|
||||
:caption="getImageDisplayCaption(block)"
|
||||
:variant="block.width"
|
||||
/>
|
||||
<ContentMarkdownCalloutEditor
|
||||
v-else-if="block.type === 'callout' && interactive"
|
||||
:callout-emoji-enabled="block.calloutEmojiEnabled"
|
||||
:callout-emoji="block.calloutEmoji"
|
||||
:callout-background="block.calloutBackground"
|
||||
:body-source-line="block.meta.startLine + 1"
|
||||
:model-value="block.text"
|
||||
@commit="onCalloutBlockCommit(block, $event)"
|
||||
/>
|
||||
<ProseCallout
|
||||
v-else-if="block.type === 'callout'"
|
||||
:emoji-enabled="block.calloutEmojiEnabled"
|
||||
@@ -1404,7 +1626,16 @@ onBeforeUnmount(() => {
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseCallout>
|
||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||
<ContentMarkdownToggleEditor
|
||||
v-else-if="block.type === 'toggle' && interactive"
|
||||
:title="block.title"
|
||||
:title-source-line="block.meta.startLine"
|
||||
:body-source-line="block.meta.startLine + 1"
|
||||
:model-value="block.text"
|
||||
@commit="onToggleBlockCommit(block, $event)"
|
||||
@insert-below="onToggleBlockInsertBelow(block, $event)"
|
||||
/>
|
||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'" animated>
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
@@ -1467,22 +1698,43 @@ onBeforeUnmount(() => {
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<pre
|
||||
<ContentMarkdownCodeBlockEditor
|
||||
v-else-if="block.type === 'code' && interactive"
|
||||
:language="block.codeLanguage"
|
||||
:show-line-numbers="block.codeShowLineNumbers"
|
||||
:body-source-line="block.meta.startLine + 1"
|
||||
:model-value="block.text"
|
||||
@commit="onCodeBlockCommit(block, $event)"
|
||||
@insert-below="onCodeBlockInsertBelow(block, $event)"
|
||||
/>
|
||||
<ProseCodeBlock
|
||||
v-else-if="block.type === 'code'"
|
||||
class="content-markdown-renderer__code overflow-x-auto rounded bg-[#15171a] px-4 py-3 mb-2.5 text-sm leading-6 text-white"
|
||||
><code>{{ block.text }}</code></pre>
|
||||
class="content-markdown-renderer__code"
|
||||
:language="block.codeLanguage"
|
||||
:show-line-numbers="block.codeShowLineNumbers"
|
||||
:line-numbers="getCodeLineNumbers(block)"
|
||||
show-copy
|
||||
:copy-text="block.text"
|
||||
>
|
||||
<code>{{ block.text }}</code>
|
||||
</ProseCodeBlock>
|
||||
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-5 border-line">
|
||||
<ContentMarkdownEditableInline
|
||||
v-else-if="block.type === 'paragraph' && interactive"
|
||||
tag="p"
|
||||
block-class="content-markdown-renderer__paragraph content-markdown-renderer__paragraph--editable mb-2.5 min-h-[1.75rem] text-base text-[var(--site-text)] last:mb-0"
|
||||
enter-mode="split-paragraph"
|
||||
allow-hard-break
|
||||
:slash-menu-active="slashMenuActive"
|
||||
:slash-command-suppressed="slashSuppressedLines.includes(block.meta.startLine)"
|
||||
:source-line="block.meta.startLine"
|
||||
:model-value="block.text"
|
||||
@commit="onParagraphInlineCommit(block, $event)"
|
||||
@split="onParagraphSplit(block, $event)"
|
||||
@delete-line="onDeleteLine"
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
||||
@slash-update="emit('slash-update', $event)"
|
||||
@slash-end="emit('slash-end', $event)"
|
||||
@slash-apply="emit('slash-apply', $event)"
|
||||
/>
|
||||
<p v-else-if="block.type === 'paragraph'" class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
|
||||
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
||||
@@ -1593,6 +1845,10 @@ onBeforeUnmount(() => {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.content-markdown-renderer {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item--interactive {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
154
components/content/ContentMarkdownToggleEditor.vue
Normal file
154
components/content/ContentMarkdownToggleEditor.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
|
||||
import ProseToggle from './ProseToggle.vue'
|
||||
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 토글 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 토글 본문 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 선언 줄 source-line(0-based) */
|
||||
titleSourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
/** 본문 첫 줄 source-line(0-based) */
|
||||
bodySourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['commit', 'insert-below'])
|
||||
|
||||
const titleEditorRef = ref(null)
|
||||
const bodyEditorRef = ref(null)
|
||||
const titleDraft = ref(props.title)
|
||||
|
||||
watch(() => props.title, (value) => {
|
||||
titleDraft.value = value
|
||||
})
|
||||
|
||||
/**
|
||||
* 토글 마크다운을 반영한다.
|
||||
* @param {{ title?: string, body?: string }} options - 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const commitToggle = (options = {}) => {
|
||||
emit('commit', buildToggleBlockLines({
|
||||
title: options.title ?? titleDraft.value,
|
||||
body: options.body ?? props.modelValue
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 편집 반영
|
||||
* @param {string} value - 제목
|
||||
* @returns {void}
|
||||
*/
|
||||
const onTitleCommit = (value) => {
|
||||
titleDraft.value = String(value ?? '').trim()
|
||||
commitToggle({ title: titleDraft.value })
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 필드 이탈 전 로컬 초안 동기화(한글 조합·↓ 이동 시 본문 오염 방지)
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncTitleDraft = () => {
|
||||
titleDraft.value = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 Enter — 본문으로 포커스 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const onTitleEnterAdvance = () => {
|
||||
const nextTitle = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
|
||||
titleDraft.value = nextTitle
|
||||
commitToggle({ title: titleDraft.value })
|
||||
|
||||
nextTick(() => {
|
||||
bodyEditorRef.value?.focusEditor('start')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 편집 반영
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyCommit = (body) => {
|
||||
commitToggle({ body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 아래로 이탈
|
||||
* @param {Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onExitBelow = (payload) => {
|
||||
emit('insert-below', payload)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProseToggle
|
||||
class="content-markdown-toggle-editor"
|
||||
data-editable-scope
|
||||
:title="titleDraft"
|
||||
default-open
|
||||
animated
|
||||
>
|
||||
<template #title>
|
||||
<ContentMarkdownEditableInline
|
||||
ref="titleEditorRef"
|
||||
tag="div"
|
||||
block-class="content-markdown-toggle-editor__title min-h-[1.75rem] outline-none"
|
||||
enter-mode="focus-next"
|
||||
navigation-scope="parent"
|
||||
:source-line="titleSourceLine"
|
||||
:model-value="titleDraft"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
@commit="onTitleCommit"
|
||||
@enter-advance="onTitleEnterAdvance"
|
||||
@leave-block="syncTitleDraft"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ContentMarkdownEditableInline
|
||||
ref="bodyEditorRef"
|
||||
tag="div"
|
||||
block-class="content-markdown-toggle-editor__body min-h-[3rem] outline-none"
|
||||
enter-mode="multiline"
|
||||
navigation-scope="parent"
|
||||
plain-text
|
||||
:source-line="bodySourceLine"
|
||||
:model-value="modelValue"
|
||||
@commit="onBodyCommit"
|
||||
@insert-below="onExitBelow"
|
||||
/>
|
||||
</ProseToggle>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-markdown-toggle-editor__title:empty::before {
|
||||
content: '토글 제목';
|
||||
color: var(--site-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-toggle-editor__body:empty::before {
|
||||
content: '펼쳤을 때 보일 내용을 입력하세요';
|
||||
color: var(--site-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
122
components/content/ProseCodeBlock.vue
Normal file
122
components/content/ProseCodeBlock.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 언어 라벨(공개 화면 표시) */
|
||||
language: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 줄 번호 표시 */
|
||||
showLineNumbers: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 복사 버튼 표시(공개 화면) */
|
||||
showCopy: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 복사할 코드 본문 */
|
||||
copyText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 줄 번호 목록 */
|
||||
lineNumbers: {
|
||||
type: Array,
|
||||
default: () => [1]
|
||||
}
|
||||
})
|
||||
|
||||
const copyDone = ref(false)
|
||||
let copyDoneTimer = null
|
||||
|
||||
/**
|
||||
* 코드 본문을 클립보드에 복사한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const copyToClipboard = async () => {
|
||||
const text = String(props.copyText ?? '')
|
||||
|
||||
if (!import.meta.client || !text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copyDone.value = true
|
||||
window.clearTimeout(copyDoneTimer)
|
||||
copyDoneTimer = window.setTimeout(() => {
|
||||
copyDone.value = false
|
||||
}, 1600)
|
||||
} catch {
|
||||
copyDone.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(copyDoneTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="prose-code-block group relative mb-2.5 overflow-x-auto rounded bg-[#15171a] text-sm leading-6 text-white"
|
||||
>
|
||||
<div
|
||||
v-if="$slots['header-tools'] || showCopy || language"
|
||||
class="prose-code-block__header absolute right-3 top-2 z-10 flex items-center gap-2"
|
||||
>
|
||||
<slot name="header-tools" />
|
||||
<span
|
||||
v-if="language && !$slots['header-tools']"
|
||||
class="prose-code-block__language text-xs text-white/50"
|
||||
>{{ language }}</span>
|
||||
<button
|
||||
v-if="showCopy"
|
||||
class="prose-code-block__copy rounded px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
type="button"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
{{ copyDone ? '복사됨' : '복사' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="prose-code-block__body flex">
|
||||
<div
|
||||
v-if="showLineNumbers"
|
||||
class="prose-code-block__gutter shrink-0 select-none border-r border-white/10 py-3 pl-3 pr-2 font-mono text-xs leading-6 text-white/40 tabular-nums"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="lineNumber in lineNumbers"
|
||||
:key="`prose-code-gutter-${lineNumber}`"
|
||||
class="prose-code-block__gutter-line"
|
||||
>
|
||||
{{ lineNumber }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose-code-block__content min-w-0 flex-1 px-4 py-3 font-mono text-sm leading-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose-code-block:focus-within {
|
||||
outline: 2px solid rgb(255 255 255 / 0.22);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.prose-code-block__content :deep(code) {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,112 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
/** 접힌 상태 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
default: ''
|
||||
},
|
||||
/** 초기 펼침 여부 */
|
||||
defaultOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 본문 열림·닫힘 애니메이션 */
|
||||
animated: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const isOpen = ref(props.defaultOpen)
|
||||
|
||||
watch(() => props.defaultOpen, (value) => {
|
||||
isOpen.value = value
|
||||
})
|
||||
|
||||
/**
|
||||
* 토글 펼침 상태를 전환한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleOpen = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<summary class="prose-toggle__summary cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]">
|
||||
{{ title }}
|
||||
</summary>
|
||||
<div class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]">
|
||||
<slot />
|
||||
<div
|
||||
class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5"
|
||||
:class="{ 'prose-toggle--open': isOpen }"
|
||||
>
|
||||
<div class="prose-toggle__header flex items-start gap-2">
|
||||
<button
|
||||
class="prose-toggle__trigger mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[var(--site-muted)] transition-colors hover:bg-black/5 hover:text-[var(--site-text)]"
|
||||
type="button"
|
||||
:aria-expanded="isOpen"
|
||||
aria-label="토글 펼치기·접기"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<svg
|
||||
class="prose-toggle__chevron size-4 transition-transform duration-300 ease-out"
|
||||
:class="{ 'prose-toggle__chevron--open': isOpen }"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 4l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="prose-toggle__title min-w-0 flex-1 text-[15px] font-semibold leading-7 text-[var(--site-text)]">
|
||||
<slot name="title">
|
||||
{{ title || '더 보기' }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div
|
||||
class="prose-toggle__body-shell"
|
||||
:class="[
|
||||
animated ? 'prose-toggle__body-shell--animated' : '',
|
||||
isOpen ? 'prose-toggle__body-shell--open' : ''
|
||||
]"
|
||||
>
|
||||
<div class="prose-toggle__body-inner min-h-0 overflow-hidden">
|
||||
<div
|
||||
class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose-toggle__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.prose-toggle__body-shell--animated {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.32s ease;
|
||||
}
|
||||
|
||||
.prose-toggle__body-shell--animated.prose-toggle__body-shell--open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated) .prose-toggle__body-inner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated).prose-toggle__body-shell--open .prose-toggle__body-inner {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
58
components/site/HomeHero.vue
Normal file
58
components/site/HomeHero.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 커버 이미지 URL */
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 오버레이 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 오버레이 본문 */
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hasOverlay = computed(() => Boolean(props.title?.trim() || props.text?.trim()))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="imageUrl"
|
||||
class="home-hero relative mx-auto w-full max-w-[720px] overflow-hidden rounded-[10px]"
|
||||
data-home-hero
|
||||
>
|
||||
<div class="home-hero__frame relative aspect-[720/215] w-full bg-[var(--site-panel)]">
|
||||
<img
|
||||
class="home-hero__cover absolute inset-0 h-full w-full object-cover"
|
||||
:src="imageUrl"
|
||||
alt=""
|
||||
loading="eager"
|
||||
>
|
||||
<div
|
||||
v-if="hasOverlay"
|
||||
class="home-hero__overlay pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent px-4 pb-4 pt-12 sm:px-5 sm:pb-5"
|
||||
>
|
||||
<div class="home-hero__overlay-inner flex flex-col items-start gap-1 text-left">
|
||||
<h2
|
||||
v-if="title"
|
||||
class="home-hero__title text-base font-semibold leading-snug text-white sm:text-lg"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="text"
|
||||
class="home-hero__text max-w-[32rem] text-sm leading-relaxed text-white/85"
|
||||
>
|
||||
{{ text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -8,16 +8,15 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="post-card site-section site-panel-hover">
|
||||
<article class="post-card site-section site-panel-hover group">
|
||||
<div class="post-card__body site-section-body flex gap-4">
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-surface object-cover"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
loading="lazy"
|
||||
>
|
||||
<div v-else class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
|
||||
<PostCardMedia
|
||||
:to="post.to"
|
||||
:title="post.title"
|
||||
:featured-image="post.featuredImage"
|
||||
link-class="h-20 w-36 shrink-0"
|
||||
aspect-class="h-full w-full"
|
||||
/>
|
||||
<div class="post-card__content min-w-0">
|
||||
<h2 class="post-card__title text-base font-semibold leading-tight">
|
||||
<NuxtLink class="post-card__title-link site-interactive hover:opacity-70" :to="post.to">
|
||||
|
||||
62
components/site/PostCardMedia.vue
Normal file
62
components/site/PostCardMedia.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 게시물 링크 */
|
||||
to: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 게시물 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 대표 이미지 URL */
|
||||
featuredImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 썸네일 비율·크기 Tailwind 클래스 */
|
||||
aspectClass: {
|
||||
type: String,
|
||||
default: 'aspect-square sm:aspect-video'
|
||||
},
|
||||
/** 링크 래퍼 추가 클래스 */
|
||||
linkClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 이미지 추가 클래스 */
|
||||
imageClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="post-card-media relative block"
|
||||
:class="linkClass"
|
||||
data-post-card-media
|
||||
>
|
||||
<figure class="post-card-media__figure overflow-hidden rounded-[10px]">
|
||||
<img
|
||||
v-if="featuredImage"
|
||||
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
|
||||
:class="[aspectClass, imageClass]"
|
||||
:src="featuredImage"
|
||||
:alt="title"
|
||||
loading="lazy"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="post-card-media__placeholder flex w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)] transition-opacity duration-200 group-hover:opacity-90"
|
||||
:class="aspectClass"
|
||||
:aria-label="title"
|
||||
>
|
||||
<span class="post-card-media__placeholder-text line-clamp-4">{{ title }}</span>
|
||||
</span>
|
||||
</figure>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -166,14 +166,6 @@ onBeforeUnmount(() => {
|
||||
@click.prevent="toggleMenu"
|
||||
>
|
||||
<span v-if="menuOpen" class="site-header__menu-icon pointer-events-none">
|
||||
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
|
||||
</svg>
|
||||
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="site-header__menu-icon pointer-events-none">
|
||||
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
||||
<path d="M9 4v16" />
|
||||
@@ -184,6 +176,14 @@ onBeforeUnmount(() => {
|
||||
<path d="m14 10 2 2-2 2" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="site-header__menu-icon pointer-events-none">
|
||||
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
|
||||
</svg>
|
||||
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||
</NuxtLink>
|
||||
@@ -195,7 +195,13 @@ onBeforeUnmount(() => {
|
||||
aria-label="검색 열기"
|
||||
@click="openSearchModal"
|
||||
>
|
||||
<span class="site-header__search-icon mr-2 text-lg leading-none">⌕</span>
|
||||
<span class="site-header__search-icon mr-2 text-lg leading-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<circle cx="10" cy="10" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="site-header__search-text site-soft">Search</span>
|
||||
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user