관리자 미리보기에서 문단·목록·인용을 contenteditable로 편집하고, Cmd+E 전환·사용자 지정 순서 번호·줄 삭제·화살표 줄 이동을 지원한다. Co-authored-by: Cursor <cursoragent@cursor.com>
662 lines
15 KiB
Vue
662 lines
15 KiB
Vue
<script setup>
|
|
import {
|
|
convertEditableHtmlToMarkdown,
|
|
getRangeInnerHtml,
|
|
markdownInlineToHtml,
|
|
markdownMultilineInlineToHtml
|
|
} from '../../lib/markdown-inline.js'
|
|
|
|
const props = defineProps({
|
|
/** 인라인 마크다운 원문 */
|
|
modelValue: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
/** 여러 줄(Shift+Enter 줄바꿈) 허용 */
|
|
multiline: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/**
|
|
* Enter 동작
|
|
* - split-paragraph: 문단 분리
|
|
* - insert-below: 블록 아래 새 줄 삽입
|
|
* - none: 기본(제목 등 단일 줄 blur)
|
|
*/
|
|
enterMode: {
|
|
type: String,
|
|
default: 'none'
|
|
},
|
|
/** @deprecated enterMode 사용 */
|
|
splitOnEnter: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** Shift+Enter 줄바꿈 허용 */
|
|
allowHardBreak: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** 원본 마크다운 줄 번호(0-based) */
|
|
sourceLine: {
|
|
type: Number,
|
|
default: null
|
|
},
|
|
/** 루트 요소 태그 */
|
|
tag: {
|
|
type: String,
|
|
default: 'div'
|
|
},
|
|
/** 추가 클래스 */
|
|
blockClass: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
/** 마크다운 원문 한 줄(백스페이스 시 표시) */
|
|
rawLine: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
/** 맨 앞 백스페이스로 원문 토글 허용 */
|
|
allowRawToggle: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['commit', 'split', 'insert-below', 'delete-line', 'raw-mode'])
|
|
|
|
const rootRef = ref(null)
|
|
const isFocused = ref(false)
|
|
const suppressBlurCommit = ref(false)
|
|
const splitLock = ref(false)
|
|
/** 조합 중 Enter 후 compositionend에서 분리할지 */
|
|
const pendingSplitAfterComposition = ref(false)
|
|
const showingRaw = ref(false)
|
|
|
|
/** @returns {string} Enter 동작 모드 */
|
|
const resolvedEnterMode = computed(() => {
|
|
if (props.enterMode !== 'none') {
|
|
return props.enterMode
|
|
}
|
|
|
|
return props.splitOnEnter ? 'split-paragraph' : 'none'
|
|
})
|
|
|
|
/**
|
|
* modelValue를 편집용 HTML로 변환한다.
|
|
* @returns {string} HTML
|
|
*/
|
|
const toEditorHtml = () => (props.multiline
|
|
? markdownMultilineInlineToHtml(props.modelValue)
|
|
: markdownInlineToHtml(props.modelValue))
|
|
|
|
/**
|
|
* 편집 영역 HTML을 동기화한다.
|
|
* @returns {void}
|
|
*/
|
|
const syncEditorHtml = () => {
|
|
if (!rootRef.value || isFocused.value) {
|
|
return
|
|
}
|
|
|
|
if (showingRaw.value && props.rawLine) {
|
|
rootRef.value.textContent = props.rawLine
|
|
return
|
|
}
|
|
|
|
rootRef.value.innerHTML = toEditorHtml()
|
|
}
|
|
|
|
watch(() => [props.modelValue, props.rawLine], () => {
|
|
if (!isFocused.value) {
|
|
showingRaw.value = false
|
|
}
|
|
|
|
syncEditorHtml()
|
|
})
|
|
|
|
onMounted(() => {
|
|
syncEditorHtml()
|
|
})
|
|
|
|
/**
|
|
* 포커스 시 편집 상태를 표시한다.
|
|
* @returns {void}
|
|
*/
|
|
const onFocus = () => {
|
|
isFocused.value = true
|
|
}
|
|
|
|
/**
|
|
* blur 시 마크다운을 반영한다.
|
|
* @returns {void}
|
|
*/
|
|
/**
|
|
* 편집 영역 값을 읽는다.
|
|
* @returns {string} 텍스트
|
|
*/
|
|
const readEditorValue = () => {
|
|
if (!rootRef.value) {
|
|
return ''
|
|
}
|
|
|
|
if (showingRaw.value) {
|
|
return rootRef.value.textContent ?? ''
|
|
}
|
|
|
|
return convertEditableHtmlToMarkdown(rootRef.value.innerHTML)
|
|
}
|
|
|
|
const onBlur = () => {
|
|
isFocused.value = false
|
|
|
|
if (!rootRef.value || suppressBlurCommit.value || splitLock.value) {
|
|
return
|
|
}
|
|
|
|
const nextValue = readEditorValue()
|
|
const changed = showingRaw.value
|
|
? nextValue !== props.rawLine
|
|
: nextValue !== props.modelValue
|
|
|
|
if (changed) {
|
|
emit('commit', showingRaw.value ? { value: nextValue, raw: true } : nextValue)
|
|
}
|
|
|
|
if (showingRaw.value) {
|
|
showingRaw.value = false
|
|
notifyRawMode(false)
|
|
}
|
|
|
|
syncEditorHtml()
|
|
}
|
|
|
|
/**
|
|
* 붙여넣기 시 서식 없는 텍스트만 삽입한다.
|
|
* @param {ClipboardEvent} event - 붙여넣기 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onPaste = (event) => {
|
|
event.preventDefault()
|
|
const text = event.clipboardData?.getData('text/plain') || ''
|
|
|
|
if (!text || !import.meta.client) {
|
|
return
|
|
}
|
|
|
|
document.execCommand('insertText', false, text)
|
|
}
|
|
|
|
/**
|
|
* 커서가 블록 맨 앞/맨 끝인지 확인한다.
|
|
* @param {'start'|'end'} edge - 확인할 위치
|
|
* @returns {boolean}
|
|
*/
|
|
const isCaretAtEdge = (edge) => {
|
|
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
|
|
}
|
|
|
|
const testRange = document.createRange()
|
|
testRange.selectNodeContents(rootRef.value)
|
|
|
|
if (edge === 'start') {
|
|
testRange.setEnd(range.startContainer, range.startOffset)
|
|
return testRange.toString().length === 0
|
|
}
|
|
|
|
testRange.setStart(range.endContainer, range.endOffset)
|
|
return testRange.toString().length === 0
|
|
}
|
|
|
|
/**
|
|
* 커서의 텍스트 오프셋을 반환한다.
|
|
* @returns {number} 루트 기준 오프셋
|
|
*/
|
|
const getCaretTextOffset = () => {
|
|
if (!import.meta.client || !rootRef.value) {
|
|
return 0
|
|
}
|
|
|
|
const selection = window.getSelection()
|
|
|
|
if (!selection || selection.rangeCount === 0) {
|
|
return 0
|
|
}
|
|
|
|
const range = selection.getRangeAt(0)
|
|
|
|
if (!rootRef.value.contains(range.commonAncestorContainer)) {
|
|
return 0
|
|
}
|
|
|
|
const testRange = document.createRange()
|
|
testRange.selectNodeContents(rootRef.value)
|
|
testRange.setEnd(range.startContainer, range.startOffset)
|
|
|
|
return testRange.toString().length
|
|
}
|
|
|
|
/**
|
|
* 원문 모드 접두사 길이를 반환한다.
|
|
* @returns {number} 접두사 길이
|
|
*/
|
|
const getRawPrefixLength = () => {
|
|
if (!showingRaw.value || !rootRef.value) {
|
|
return 0
|
|
}
|
|
|
|
const text = rootRef.value.textContent ?? ''
|
|
const match = text.match(/^(\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+\.\s+))/)
|
|
|
|
return match ? match[1].length : 0
|
|
}
|
|
|
|
/**
|
|
* 커서가 줄의 논리적 맨 앞인지 확인한다(원문 접두사 직후 포함).
|
|
* @returns {boolean}
|
|
*/
|
|
const isCaretAtLogicalStart = () => {
|
|
if (isCaretAtEdge('start')) {
|
|
return true
|
|
}
|
|
|
|
const prefixLength = getRawPrefixLength()
|
|
|
|
if (!prefixLength) {
|
|
return false
|
|
}
|
|
|
|
return getCaretTextOffset() <= prefixLength
|
|
}
|
|
|
|
/**
|
|
* 커서가 줄의 논리적 맨 끝인지 확인한다.
|
|
* @returns {boolean}
|
|
*/
|
|
const isCaretAtLogicalEnd = () => isCaretAtEdge('end')
|
|
|
|
/**
|
|
* 이전·다음 편집 줄로 포커스를 옮긴다.
|
|
* @param {number} direction - -1 이전 줄, 1 다음 줄
|
|
* @param {boolean} cursorAtStart - 커서를 줄 앞에 둘지
|
|
* @returns {void}
|
|
*/
|
|
const navigateToAdjacentLine = (direction, cursorAtStart) => {
|
|
if (!import.meta.client || props.sourceLine === null) {
|
|
return
|
|
}
|
|
|
|
const container = rootRef.value?.closest('.content-markdown-renderer')
|
|
|
|
if (!container) {
|
|
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
|
|
)
|
|
|
|
if (currentIndex < 0) {
|
|
return
|
|
}
|
|
|
|
const target = elements[currentIndex + direction]
|
|
|
|
if (!target) {
|
|
return
|
|
}
|
|
|
|
target.focus()
|
|
const range = document.createRange()
|
|
range.selectNodeContents(target)
|
|
range.collapse(cursorAtStart)
|
|
const selection = window.getSelection()
|
|
selection?.removeAllRanges()
|
|
selection?.addRange(range)
|
|
}
|
|
|
|
/**
|
|
* 커서 위치에서 문단을 분리한다.
|
|
* @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 })
|
|
}
|
|
|
|
/**
|
|
* Enter 처리(분리·아래 삽입)를 한 번만 실행한다.
|
|
* @param {'split'|'insert-below'} action - 동작
|
|
* @returns {void}
|
|
*/
|
|
const scheduleEnterAction = (action) => {
|
|
if (splitLock.value) {
|
|
return
|
|
}
|
|
|
|
splitLock.value = true
|
|
suppressBlurCommit.value = true
|
|
|
|
if (action === 'split') {
|
|
splitAtCaret()
|
|
} else {
|
|
const nextValue = readEditorValue()
|
|
emit('insert-below', showingRaw.value ? { value: nextValue, raw: true } : nextValue)
|
|
}
|
|
|
|
nextTick(() => {
|
|
splitLock.value = false
|
|
window.setTimeout(() => {
|
|
suppressBlurCommit.value = false
|
|
}, 50)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 원문 모드 상태를 부모에 알린다.
|
|
* @param {boolean} active - 활성 여부
|
|
* @returns {void}
|
|
*/
|
|
const notifyRawMode = (active) => {
|
|
if (props.sourceLine === null) {
|
|
return
|
|
}
|
|
|
|
emit('raw-mode', { sourceLine: props.sourceLine, active })
|
|
}
|
|
|
|
/**
|
|
* 원문 모드 커서를 접두사 뒤에 둔다.
|
|
* @returns {void}
|
|
*/
|
|
const placeCursorAfterPrefix = () => {
|
|
if (!rootRef.value) {
|
|
return
|
|
}
|
|
|
|
const text = rootRef.value.textContent ?? ''
|
|
const match = text.match(/^(\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+\.\s+))/)
|
|
|
|
if (!match) {
|
|
return
|
|
}
|
|
|
|
const offset = match[1].length
|
|
const walker = document.createTreeWalker(rootRef.value, NodeFilter.SHOW_TEXT)
|
|
let node = walker.nextNode()
|
|
let remaining = offset
|
|
|
|
while (node) {
|
|
const length = node.textContent?.length ?? 0
|
|
|
|
if (remaining <= length) {
|
|
const range = document.createRange()
|
|
range.setStart(node, remaining)
|
|
range.collapse(true)
|
|
const selection = window.getSelection()
|
|
selection?.removeAllRanges()
|
|
selection?.addRange(range)
|
|
return
|
|
}
|
|
|
|
remaining -= length
|
|
node = walker.nextNode()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 키보드 입력 처리
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onKeydown = (event) => {
|
|
const isCmdE = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'e'
|
|
|
|
if (isCmdE) {
|
|
return
|
|
}
|
|
|
|
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k') {
|
|
if (props.sourceLine !== null) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
emit('delete-line', props.sourceLine)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (
|
|
event.key === 'Backspace'
|
|
&& isCaretAtEdge('start')
|
|
&& !showingRaw.value
|
|
&& props.sourceLine !== null
|
|
&& !readEditorValue().trim()
|
|
) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
emit('delete-line', props.sourceLine)
|
|
return
|
|
}
|
|
|
|
if (
|
|
event.key === 'Backspace'
|
|
&& props.allowRawToggle
|
|
&& props.rawLine
|
|
&& !showingRaw.value
|
|
&& isCaretAtEdge('start')
|
|
) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
showingRaw.value = true
|
|
notifyRawMode(true)
|
|
nextTick(() => {
|
|
if (!rootRef.value) {
|
|
return
|
|
}
|
|
|
|
rootRef.value.textContent = props.rawLine
|
|
placeCursorAfterPrefix()
|
|
})
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowUp' && isCaretAtLogicalStart()) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
navigateToAdjacentLine(-1, false)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowDown' && isCaretAtLogicalEnd()) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
navigateToAdjacentLine(1, true)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
navigateToAdjacentLine(-1, false)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
navigateToAdjacentLine(1, true)
|
|
return
|
|
}
|
|
|
|
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
if (event.isComposing || event.keyCode === 229) {
|
|
pendingSplitAfterComposition.value = true
|
|
return
|
|
}
|
|
|
|
pendingSplitAfterComposition.value = false
|
|
scheduleEnterAction(enterMode === 'split-paragraph' ? 'split' : 'insert-below')
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Enter' && event.shiftKey && props.allowHardBreak) {
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Enter' && !props.multiline && enterMode === 'none') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
rootRef.value?.blur()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 한글 등 IME 조합 종료 후 Enter 처리
|
|
* @returns {void}
|
|
*/
|
|
const onCompositionEnd = () => {
|
|
if (!pendingSplitAfterComposition.value) {
|
|
return
|
|
}
|
|
|
|
pendingSplitAfterComposition.value = false
|
|
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
|
|
|
if (enterMode !== 'split-paragraph' && enterMode !== 'insert-below') {
|
|
return
|
|
}
|
|
|
|
nextTick(() => {
|
|
scheduleEnterAction(enterMode === 'split-paragraph' ? 'split' : 'insert-below')
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 외부에서 포커스·커서를 둔다.
|
|
* @param {'start'|'end'} position - 커서 위치
|
|
* @returns {void}
|
|
*/
|
|
const focusEditor = (position = 'end') => {
|
|
if (!import.meta.client || !rootRef.value) {
|
|
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)
|
|
}
|
|
|
|
defineExpose({ focusEditor })
|
|
</script>
|
|
|
|
<template>
|
|
<component
|
|
:is="tag"
|
|
ref="rootRef"
|
|
class="content-markdown-editable-inline min-w-0 outline-none"
|
|
:class="[
|
|
blockClass,
|
|
isFocused ? 'content-markdown-editable-inline--focused' : 'content-markdown-editable-inline--idle',
|
|
showingRaw ? 'content-markdown-editable-inline--raw' : ''
|
|
]"
|
|
:data-source-line="sourceLine ?? undefined"
|
|
contenteditable="true"
|
|
spellcheck="true"
|
|
@focus="onFocus"
|
|
@blur="onBlur"
|
|
@paste="onPaste"
|
|
@keydown="onKeydown"
|
|
@compositionend="onCompositionEnd"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.content-markdown-editable-inline--idle {
|
|
cursor: text;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.content-markdown-editable-inline--focused {
|
|
background-color: transparent;
|
|
}
|
|
|
|
.content-markdown-editable-inline--raw {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 0.92em;
|
|
color: #5c6570;
|
|
}
|
|
|
|
.content-markdown-editable-inline :deep(a) {
|
|
color: var(--site-accent);
|
|
text-decoration: underline;
|
|
text-underline-offset: 4px;
|
|
}
|
|
|
|
.content-markdown-editable-inline :deep(code) {
|
|
border-radius: 0.25rem;
|
|
background: #252525;
|
|
padding: 0.125rem 0.375rem;
|
|
font-size: 0.9em;
|
|
color: #fff;
|
|
}
|
|
</style>
|