Selection Bridge로 블록 간 선택·삭제를 보강하고, 콜아웃·인용 멀티라인 Enter·전체 선택 삭제·한글 IME 문제를 수정했다. Obsidian식 위첨자 문법과 RightSidebar 패딩·커스텀 아이콘 색상도 함께 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
1522 lines
36 KiB
Vue
1522 lines
36 KiB
Vue
<script setup>
|
|
import {
|
|
getEditableCaretOffset,
|
|
markdownInlineToHtml,
|
|
readEditableTextFromElement,
|
|
setEditableCaretOffset
|
|
} from '../../lib/markdown-inline.js'
|
|
import {
|
|
isEditableElementFullySelected,
|
|
isLiveSelectionDeleteKey,
|
|
LIVE_SELECTION_BRIDGE_KEY,
|
|
selectEditableElementContents
|
|
} from '../../lib/markdown-live-selection.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
|
|
},
|
|
/** 전체 선택 삭제 시 비어 있는 원본 줄을 보존할지 여부 */
|
|
preserveEmptyLineOnFullDelete: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** 전체 선택 삭제 후 남길 빈 원본 줄 */
|
|
emptyMarkdownLine: {
|
|
type: String,
|
|
default: ''
|
|
}
|
|
})
|
|
|
|
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)
|
|
/** 부모가 병합·분리 등 구조 변경을 반영한 시각(ms) */
|
|
const lastStructuralModelSyncAt = ref(0)
|
|
/** 한글 등 IME 조합 중인지 여부 */
|
|
const isComposingText = ref(false)
|
|
/** @type {import('vue').Ref<'split'|'insert-below'|'newline'|'focus-next'|'blur'|null>} */
|
|
const pendingComposedEnterAction = ref(null)
|
|
/** 마지막 IME 조합 종료 시각(ms) */
|
|
const lastCompositionEndAt = ref(0)
|
|
/** 조합 확정 Enter 후 블록 동작을 실행한 마지막 시각(ms) */
|
|
const lastComposedEnterHandledAt = ref(0)
|
|
const showingRaw = ref(false)
|
|
/** 마지막 블록 전체 선택 시각(ms) */
|
|
const lastBlockSelectAllAt = ref(0)
|
|
let cleanupComposedEnterSuppressor = null
|
|
|
|
const liveSelectionBridge = inject(LIVE_SELECTION_BRIDGE_KEY, 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을 동기화한다.
|
|
* @param {{ force?: boolean }} [options] - 포커스 중에도 강제 동기화할지
|
|
* @returns {void}
|
|
*/
|
|
const syncEditorHtml = (options = {}) => {
|
|
const force = options.force === true
|
|
|
|
if (!rootRef.value || (isFocused.value && !force)) {
|
|
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()
|
|
}
|
|
|
|
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, { trimEnd: !props.plainText })
|
|
}
|
|
|
|
/**
|
|
* 부모 modelValue와 편집 DOM이 어긋났는지 확인한다.
|
|
* @returns {boolean}
|
|
*/
|
|
const isEditorOutOfSyncWithModel = () => {
|
|
const model = String(props.modelValue ?? '')
|
|
|
|
if (showingRaw.value) {
|
|
return (rootRef.value?.textContent ?? '') !== String(props.rawLine ?? '')
|
|
}
|
|
|
|
return readEditorValue() !== model
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if ((suppressBlurCommit.value || splitLock.value) && isEditorOutOfSyncWithModel()) {
|
|
lastStructuralModelSyncAt.value = Date.now()
|
|
syncEditorHtml({ force: true })
|
|
}
|
|
})
|
|
|
|
const onBlur = () => {
|
|
isFocused.value = false
|
|
|
|
if (!rootRef.value || suppressBlurCommit.value || splitLock.value) {
|
|
return
|
|
}
|
|
|
|
const modelValue = String(props.modelValue ?? '')
|
|
const nextValue = readEditorValue()
|
|
|
|
if (
|
|
Date.now() - lastStructuralModelSyncAt.value < 500
|
|
&& modelValue
|
|
&& nextValue !== modelValue
|
|
&& nextValue.startsWith(modelValue)
|
|
&& nextValue.length > modelValue.length
|
|
) {
|
|
syncEditorHtml()
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/**
|
|
* plain text 편집 영역에서 현재 선택 범위를 텍스트로 교체한다.
|
|
* @param {string} text - 삽입할 텍스트
|
|
* @returns {boolean} 삽입 여부
|
|
*/
|
|
const replacePlainTextSelection = (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
|
|
}
|
|
|
|
const startRange = document.createRange()
|
|
startRange.setStart(range.startContainer, range.startOffset)
|
|
startRange.collapse(true)
|
|
|
|
const endRange = document.createRange()
|
|
endRange.setStart(range.endContainer, range.endOffset)
|
|
endRange.collapse(true)
|
|
|
|
const startOffset = getEditableCaretOffset(rootRef.value, startRange)
|
|
const endOffset = getEditableCaretOffset(rootRef.value, endRange)
|
|
const from = Math.min(startOffset, endOffset)
|
|
const to = Math.max(startOffset, endOffset)
|
|
const value = readEditorValue()
|
|
const nextValue = `${value.slice(0, from)}${text}${value.slice(to)}`
|
|
|
|
rootRef.value.textContent = nextValue
|
|
setEditableCaretOffset(rootRef.value, from + text.length)
|
|
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') || getCaretTextOffset() >= readEditorValue().length
|
|
|
|
/**
|
|
* 커서가 위치한 시각 줄 정보를 반환한다.
|
|
* @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())
|
|
}
|
|
|
|
/**
|
|
* 줄 삭제·병합 같은 구조 변경 직후 stale DOM 커밋을 잠시 차단한다.
|
|
* @returns {void}
|
|
*/
|
|
const beginStructuralEdit = () => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
suppressBlurCommit.value = true
|
|
lastStructuralModelSyncAt.value = Date.now()
|
|
window.setTimeout(() => {
|
|
suppressBlurCommit.value = false
|
|
}, 400)
|
|
}
|
|
|
|
/**
|
|
* Enter 처리(분리·아래 삽입)를 한 번만 실행한다.
|
|
* @param {'split'|'insert-below'} action - 동작
|
|
* @returns {void}
|
|
*/
|
|
const scheduleEnterAction = (action) => {
|
|
if (splitLock.value) {
|
|
return
|
|
}
|
|
|
|
splitLock.value = true
|
|
suppressBlurCommit.value = true
|
|
lastStructuralModelSyncAt.value = Date.now()
|
|
|
|
if (action === 'split') {
|
|
splitAtCaret()
|
|
} else {
|
|
emit('insert-below', buildInsertBelowPayload())
|
|
}
|
|
|
|
nextTick(() => {
|
|
splitLock.value = false
|
|
window.setTimeout(() => {
|
|
suppressBlurCommit.value = false
|
|
}, 400)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Enter 모드에 맞는 실행 동작을 반환한다.
|
|
* @param {string} enterMode - Enter 모드
|
|
* @returns {'split'|'insert-below'|'newline'|'focus-next'|'blur'|null} 실행 동작
|
|
*/
|
|
const getEnterAction = (enterMode) => {
|
|
if (enterMode === 'split-paragraph') {
|
|
return 'split'
|
|
}
|
|
|
|
if (enterMode === 'insert-below') {
|
|
return 'insert-below'
|
|
}
|
|
|
|
if (enterMode === 'multiline') {
|
|
return 'newline'
|
|
}
|
|
|
|
if (enterMode === 'focus-next') {
|
|
return 'focus-next'
|
|
}
|
|
|
|
if (enterMode === 'none') {
|
|
return 'blur'
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* 저장된 Enter 동작을 실행한다.
|
|
* @param {'split'|'insert-below'|'newline'|'focus-next'|'blur'} action - 실행 동작
|
|
* @returns {void}
|
|
*/
|
|
const runEnterAction = (action) => {
|
|
if (action === 'split' || action === 'insert-below') {
|
|
scheduleEnterAction(action)
|
|
return
|
|
}
|
|
|
|
if (action === 'newline') {
|
|
const inserted = props.plainText
|
|
? replacePlainTextSelection('\n')
|
|
: insertTextAtSelection('\n')
|
|
|
|
if (!inserted) {
|
|
return
|
|
}
|
|
|
|
nextTick(onEditorInput)
|
|
return
|
|
}
|
|
|
|
if (action === 'focus-next') {
|
|
emit('enter-advance')
|
|
return
|
|
}
|
|
|
|
if (action === 'blur') {
|
|
rootRef.value?.blur()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 조합 종료 직후 브라우저가 다시 전달하는 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()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shift 선택이 인접 블록으로 확장되어야 하는지 확인한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {boolean}
|
|
*/
|
|
const shouldExtendSelectionAcrossBlocks = (event) => {
|
|
if (!liveSelectionBridge || props.sourceLine === null) {
|
|
return false
|
|
}
|
|
|
|
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) {
|
|
return false
|
|
}
|
|
|
|
const key = event.key
|
|
const lineContext = getCaretLineContext()
|
|
const isMultilineEditor = resolvedEnterMode.value === 'multiline'
|
|
|
|
if (key === 'ArrowDown') {
|
|
return isMultilineEditor
|
|
? lineContext.isLastLine
|
|
: true
|
|
}
|
|
|
|
if (key === 'ArrowUp') {
|
|
return isMultilineEditor
|
|
? lineContext.isFirstLine
|
|
: true
|
|
}
|
|
|
|
if (key === 'ArrowLeft') {
|
|
return isCaretAtLogicalStart()
|
|
}
|
|
|
|
if (key === 'ArrowRight') {
|
|
return isCaretAtLogicalEnd()
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Shift 범위 선택 등 블록 내부 기본 텍스트 선택 동작인지 확인한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {boolean}
|
|
*/
|
|
const shouldPreserveNativeTextSelection = (event) => {
|
|
if (event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
if (shouldExtendSelectionAcrossBlocks(event)) {
|
|
return false
|
|
}
|
|
|
|
return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Cmd/Ctrl+A 단계 선택을 처리한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {boolean} 처리 여부
|
|
*/
|
|
const handleSelectAllShortcut = (event) => {
|
|
if (!rootRef.value) {
|
|
return false
|
|
}
|
|
|
|
const hasCommandModifier = event.metaKey || event.ctrlKey
|
|
|
|
if (!hasCommandModifier || event.shiftKey || event.altKey || event.key.toLowerCase() !== 'a') {
|
|
return false
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
const now = Date.now()
|
|
const isBlockFullySelected = isEditableElementFullySelected(rootRef.value)
|
|
|
|
if (isBlockFullySelected && now - lastBlockSelectAllAt.value < 1200 && liveSelectionBridge) {
|
|
liveSelectionBridge.selectDocument()
|
|
lastBlockSelectAllAt.value = 0
|
|
return true
|
|
}
|
|
|
|
selectEditableElementContents(rootRef.value)
|
|
lastBlockSelectAllAt.value = now
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Shift 선택을 인접 블록으로 확장한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {boolean} 처리 여부
|
|
*/
|
|
const handleCrossBlockSelection = (event) => {
|
|
if (!shouldExtendSelectionAcrossBlocks(event) || !liveSelectionBridge) {
|
|
return false
|
|
}
|
|
|
|
const lineContext = getCaretLineContext()
|
|
let direction = 0
|
|
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
direction = 1
|
|
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
direction = -1
|
|
}
|
|
|
|
if (!direction) {
|
|
return false
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
const resolvedSourceLine = Number(rootRef.value?.getAttribute('data-source-line'))
|
|
|
|
liveSelectionBridge.extendSelection({
|
|
sourceLine: Number.isInteger(resolvedSourceLine) ? resolvedSourceLine : props.sourceLine,
|
|
direction,
|
|
column: lineContext.column,
|
|
navigationScope: props.navigationScope
|
|
})
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* 키보드 입력 처리
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onKeydown = (event) => {
|
|
const isCmdE = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'e'
|
|
|
|
if (isCmdE) {
|
|
return
|
|
}
|
|
|
|
if (handleSelectAllShortcut(event)) {
|
|
return
|
|
}
|
|
|
|
if (isLiveSelectionDeleteKey(event) && liveSelectionBridge?.deleteSelection?.()) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return
|
|
}
|
|
|
|
if (handleCrossBlockSelection(event)) {
|
|
return
|
|
}
|
|
|
|
if (shouldPreserveNativeTextSelection(event)) {
|
|
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()
|
|
beginStructuralEdit()
|
|
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()
|
|
beginStructuralEdit()
|
|
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()
|
|
beginStructuralEdit()
|
|
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.shiftKey && (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.shiftKey && event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
navigateToAdjacentBlock(-1, 0, 'block-end')
|
|
return
|
|
}
|
|
|
|
if (!hasCommandModifier && !event.altKey && !event.shiftKey && event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
navigateToAdjacentBlock(1, 0, 'block-start')
|
|
return
|
|
}
|
|
|
|
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
|
const enterAction = getEnterAction(enterMode)
|
|
const isComposingEnter = event.key === 'Enter'
|
|
&& (event.isComposing || event.keyCode === 229 || isComposingText.value)
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey && !isComposingEnter && parseSlashInput(readEditorValue())) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
event.stopImmediatePropagation?.()
|
|
emit('slash-apply', {
|
|
sourceLine: props.sourceLine,
|
|
value: readEditorValue()
|
|
})
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Enter' && enterAction) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
if (isComposingEnter) {
|
|
pendingComposedEnterAction.value = enterAction
|
|
return
|
|
}
|
|
|
|
pendingComposedEnterAction.value = null
|
|
runEnterAction(enterAction)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 한글 등 IME 조합 시작 상태를 기록한다.
|
|
* @returns {void}
|
|
*/
|
|
const onCompositionStart = () => {
|
|
isComposingText.value = true
|
|
}
|
|
|
|
/**
|
|
* 한글 등 IME 조합 종료 후 저장된 Enter 동작을 실행한다.
|
|
* @returns {void}
|
|
*/
|
|
const onCompositionEnd = () => {
|
|
isComposingText.value = false
|
|
lastCompositionEndAt.value = Date.now()
|
|
|
|
const action = pendingComposedEnterAction.value
|
|
pendingComposedEnterAction.value = null
|
|
|
|
if (!action) {
|
|
return
|
|
}
|
|
|
|
nextTick(() => {
|
|
suppressNextComposedEnterGlobally()
|
|
lastComposedEnterHandledAt.value = Date.now()
|
|
runEnterAction(action)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 일부 IME는 조합 확정 Enter의 keydown을 전달하지 않고 keyup만 남긴다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onKeyup = (event) => {
|
|
const now = Date.now()
|
|
|
|
if (
|
|
event.key !== 'Enter'
|
|
|| event.metaKey
|
|
|| event.ctrlKey
|
|
|| event.altKey
|
|
|| now - lastCompositionEndAt.value > 500
|
|
|| now - lastComposedEnterHandledAt.value < 300
|
|
) {
|
|
return
|
|
}
|
|
|
|
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
|
const action = getEnterAction(enterMode)
|
|
|
|
if (!action) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
suppressNextComposedEnterGlobally()
|
|
lastComposedEnterHandledAt.value = now
|
|
runEnterAction(action)
|
|
}
|
|
|
|
/**
|
|
* 외부에서 포커스·커서를 둔다.
|
|
* @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"
|
|
:data-empty-markdown-line="preserveEmptyLineOnFullDelete ? emptyMarkdownLine : undefined"
|
|
contenteditable="true"
|
|
spellcheck="true"
|
|
@focus="onFocus"
|
|
@blur="onBlur"
|
|
@input="onEditorInput"
|
|
@paste="onPaste"
|
|
@keydown="onKeydown"
|
|
@keyup="onKeyup"
|
|
@compositionstart="onCompositionStart"
|
|
@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>
|