Files
sori.studio/components/content/ContentMarkdownEditableInline.vue

1175 lines
27 KiB
Vue

<script setup>
import {
getEditableCaretOffset,
markdownInlineToHtml,
readEditableTextFromElement,
setEditableCaretOffset
} from '../../lib/markdown-inline.js'
import { parseSlashInput } from '../../lib/markdown-slash-commands.js'
const props = defineProps({
/** 인라인 마크다운 원문 */
modelValue: {
type: String,
default: ''
},
/**
* 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
},
/** 원본 마크다운 줄 번호(0-based) */
sourceLine: {
type: Number,
default: null
},
/** 이 편집 영역이 대표하는 원본 줄 수 */
sourceLineCount: {
type: Number,
default: 1
},
/** 루트 요소 태그 */
tag: {
type: String,
default: 'div'
},
/** 추가 클래스 */
blockClass: {
type: String,
default: ''
},
/** 마크다운 원문 한 줄(백스페이스 시 표시) */
rawLine: {
type: String,
default: ''
},
/** 맨 앞 백스페이스로 원문 토글 허용 */
allowRawToggle: {
type: Boolean,
default: false
},
/** 슬래시 메뉴가 열려 키보드 탐색 중인지 */
slashMenuActive: {
type: Boolean,
default: false
},
/** 마지막 줄 아래 방향키로 외부 줄을 만들지 여부 */
arrowExitCreatesLine: {
type: Boolean,
default: false
},
/** ESC 등으로 이 줄의 슬래시 메뉴를 닫은 상태(문자 `/`는 유지) */
slashCommandSuppressed: {
type: Boolean,
default: false
},
/** true면 인라인 마크다운 변환 없이 줄바꿈 유지(코드 블록 등) */
plainText: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'commit',
'input',
'split',
'insert-above',
'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)
const suppressBlurCommit = ref(false)
const splitLock = ref(false)
/** 조합 중 Enter 후 compositionend에서 분리할지 */
const pendingSplitAfterComposition = ref(false)
const showingRaw = ref(false)
let cleanupComposedEnterSuppressor = null
/** @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 = () => markdownInlineToHtml(props.modelValue.replace(/\n/g, ' '))
/**
* plainText 모드용 편집 텍스트를 만든다.
* @param {string} value - 본문
* @returns {string} 텍스트
*/
const plainTextToEditorText = (value) => String(value ?? '')
/**
* 편집 영역 HTML을 동기화한다.
* @returns {void}
*/
const syncEditorHtml = () => {
if (!rootRef.value || isFocused.value) {
return
}
if (showingRaw.value && props.rawLine) {
rootRef.value.textContent = props.rawLine
return
}
if (props.plainText) {
rootRef.value.textContent = plainTextToEditorText(props.modelValue)
return
}
rootRef.value.innerHTML = toEditorHtml()
}
watch(() => [props.modelValue, props.rawLine], () => {
if (!isFocused.value) {
showingRaw.value = false
syncEditorHtml()
return
}
if (showingRaw.value) {
if (props.rawLine && rootRef.value && rootRef.value.textContent !== props.rawLine) {
rootRef.value.textContent = props.rawLine
}
return
}
})
onMounted(() => {
syncEditorHtml()
})
onBeforeUnmount(() => {
cleanupComposedEnterSuppressor?.()
})
/**
* 포커스 시 편집 상태를 표시한다.
* @returns {void}
*/
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()
emit('input', readEditorValue())
}
/**
* blur 시 마크다운을 반영한다.
* @returns {void}
*/
/**
* 편집 영역 값을 읽는다.
* @returns {string} 텍스트
*/
const readEditorValue = () => {
if (!rootRef.value) {
return ''
}
if (showingRaw.value) {
return rootRef.value.textContent ?? ''
}
return readEditableTextFromElement(rootRef.value)
}
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 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
}
/**
* 현재 선택 영역에 순수 텍스트를 삽입한다.
* @param {string} text - 삽입할 텍스트
* @returns {boolean} 삽입 여부
*/
const insertTextAtSelection = (text) => {
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
}
range.deleteContents()
const textNode = document.createTextNode(text)
range.insertNode(textNode)
range.setStartAfter(textNode)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
return true
}
/**
* 커서가 줄의 논리적 맨 앞인지 확인한다(원문 접두사 직후 포함).
* @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')
/**
* 커서가 위치한 시각 줄 정보를 반환한다.
* @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}
*/
/**
* ↑↓ 이동 대상 편집 요소 목록을 반환한다.
* @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]')]
.filter((element) => element.getAttribute('contenteditable') === 'true'
|| element.getAttribute('data-preview-card-block') === 'true')
.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 elements = getNavigationElements()
if (!elements.length) {
return
}
const currentIndex = elements.findIndex(
(element) => Number(element.dataset.sourceLine) === props.sourceLine
)
if (currentIndex < 0) {
return
}
const target = elements[currentIndex + direction]
if (!target) {
if (!props.arrowExitCreatesLine) {
return
}
if (direction === -1) {
emit('insert-above', buildInsertBelowPayload())
} else {
emit('insert-below', buildInsertBelowPayload())
}
return
}
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()
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()
}
}
/**
* 편집 영역의 현재 선택 텍스트를 링크 마크다운으로 바꾼다.
* @returns {void}
*/
const insertMarkdownLinkAtSelection = () => {
if (!import.meta.client || !rootRef.value) {
return
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return
}
const selectedText = selection.toString() || '링크 텍스트'
const markdown = `[${selectedText}](https://)`
document.execCommand('insertText', false, markdown)
syncSlashState()
nextTick(() => {
if (!rootRef.value) {
return
}
emit('input', readEditorValue())
emit('commit', readEditorValue())
})
}
/**
* 커서 위치에서 문단을 분리한다.
* @returns {void}
*/
const splitAtCaret = () => {
emit('split', readCaretSplit())
}
/**
* 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 {
emit('insert-below', buildInsertBelowPayload())
}
nextTick(() => {
splitLock.value = false
window.setTimeout(() => {
suppressBlurCommit.value = false
}, 180)
})
}
/**
* 조합 종료 직후 브라우저가 다시 전달하는 Enter를 한 번 차단한다.
* @returns {void}
*/
const suppressNextComposedEnterGlobally = () => {
if (!import.meta.client) {
return
}
cleanupComposedEnterSuppressor?.()
const deadline = Date.now() + 500
const handler = (event) => {
if (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& Date.now() <= deadline
) {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation?.()
cleanupComposedEnterSuppressor?.()
}
}
cleanupComposedEnterSuppressor = () => {
window.removeEventListener('keydown', handler, true)
cleanupComposedEnterSuppressor = null
}
window.addEventListener('keydown', handler, true)
window.setTimeout(() => {
cleanupComposedEnterSuppressor?.()
}, 520)
}
/**
* 원문 모드 상태를 부모에 알린다.
* @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') {
event.preventDefault()
event.stopPropagation()
insertMarkdownLinkAtSelection()
return
}
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k') {
if (props.sourceLine !== null) {
const lineContext = getCaretLineContext()
const sourceLine = resolvedEnterMode.value === 'multiline'
? props.sourceLine + lineContext.lineIndex
: props.sourceLine
event.preventDefault()
event.stopPropagation()
emit('delete-line', sourceLine)
}
return
}
if (
event.key === 'Backspace'
&& !hasNonCollapsedSelection()
&& isCaretAtLogicalStart()
&& !showingRaw.value
&& props.sourceLine !== null
&& readEditorValue().trim()
) {
const lineContext = getCaretLineContext()
if (resolvedEnterMode.value === 'multiline' && !lineContext.isFirstLine) {
return
}
event.preventDefault()
event.stopPropagation()
emit('merge-with-previous', buildInsertBelowPayload())
return
}
if (
event.key === 'Backspace'
&& !hasNonCollapsedSelection()
&& isCaretAtLogicalStart()
&& !showingRaw.value
&& props.sourceLine !== null
&& !readEditorValue().trim()
) {
const lineContext = getCaretLineContext()
if (resolvedEnterMode.value === 'multiline' && !lineContext.isFirstLine) {
return
}
const sourceLine = resolvedEnterMode.value === 'multiline'
? props.sourceLine + lineContext.lineIndex
: props.sourceLine
event.preventDefault()
event.stopPropagation()
emit('delete-line', sourceLine)
return
}
if (
event.key === 'Backspace'
&& !hasNonCollapsedSelection()
&& 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
}
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 (!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()
navigateToAdjacentBlock(direction, lineContext.column)
return
}
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentBlock(-1, 0, 'block-end')
return
}
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentBlock(1, 0, 'block-start')
return
}
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation?.()
emit('slash-apply', {
sourceLine: props.sourceLine,
value: readEditorValue()
})
return
}
if (event.key === 'Enter' && (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' && enterMode === 'multiline') {
event.preventDefault()
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) {
return
}
if (!insertTextAtSelection('\n')) {
return
}
nextTick(onEditorInput)
return
}
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' && 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(() => {
suppressNextComposedEnterGlobally()
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({ preventScroll: true })
const textLength = readEditorValue().length
const offset = position === 'start' ? 0 : textLength
setEditableCaretOffset(rootRef.value, offset)
}
defineExpose({ focusEditor, readEditorValue })
</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',
plainText ? 'content-markdown-editable-inline--plain' : '',
showingRaw ? 'content-markdown-editable-inline--raw' : ''
]"
:data-source-line="sourceLine ?? undefined"
:data-source-line-end="sourceLine !== null ? sourceLine + Math.max(1, sourceLineCount) - 1 : undefined"
contenteditable="true"
spellcheck="true"
@focus="onFocus"
@blur="onBlur"
@input="onEditorInput"
@paste="onPaste"
@keydown="onKeydown"
@compositionend="onCompositionEnd"
/>
</template>
<style scoped>
.content-markdown-editable-inline {
user-select: text;
}
.content-markdown-editable-inline--idle {
cursor: text;
border-radius: 4px;
}
.content-markdown-editable-inline--focused {
background-color: transparent;
}
.content-markdown-editable-inline--plain {
white-space: pre-wrap;
}
.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>