라이브 편집 선택·콜아웃·인용 안정화 및 오른쪽 사이드바 여백 보정 (v1.5.70)
Selection Bridge로 블록 간 선택·삭제를 보강하고, 콜아웃·인용 멀티라인 Enter·전체 선택 삭제·한글 IME 문제를 수정했다. Obsidian식 위첨자 문법과 RightSidebar 패딩·커스텀 아이콘 색상도 함께 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1804,6 +1804,19 @@ const onPreviewBlockContentChange = ({ startLine, endLine, replacementLines }) =
|
||||
replaceLineRange(startLine, resolveCurrentFencedBlockEndLine(startLine, endLine), replacementLines, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드 교차 선택 삭제 결과를 본문에 반영한다.
|
||||
* @param {{ value: string }} payload - 갱신된 마크다운
|
||||
* @returns {void}
|
||||
*/
|
||||
const onPreviewContentReplace = ({ value }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
markdownValue.value = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드 하단에서 새 문단 줄을 추가한다.
|
||||
* @returns {void}
|
||||
@@ -1862,6 +1875,23 @@ const onPreviewDeleteLine = (lineIndex) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const handlePreviewKeydownCapture = (event) => {
|
||||
const target = event.target
|
||||
const isSelectAllShortcut = (event.metaKey || event.ctrlKey)
|
||||
&& !event.shiftKey
|
||||
&& !event.altKey
|
||||
&& event.key.toLowerCase() === 'a'
|
||||
|
||||
if (isSelectAllShortcut) {
|
||||
if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
previewRendererRef.value?.selectAllLiveDocument()
|
||||
return
|
||||
}
|
||||
|
||||
const isDeleteShortcut = (event.metaKey || event.ctrlKey)
|
||||
&& event.shiftKey
|
||||
&& event.key.toLowerCase() === 'k'
|
||||
@@ -1870,8 +1900,6 @@ const handlePreviewKeydownCapture = (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
|
||||
if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) {
|
||||
return
|
||||
}
|
||||
@@ -3093,6 +3121,7 @@ const handleKeydown = (event) => {
|
||||
@extract-gallery-image="onPreviewExtractGalleryImage"
|
||||
@remove-gallery-image="onPreviewRemoveGalleryImage"
|
||||
@block-content-change="onPreviewBlockContentChange"
|
||||
@content-replace="onPreviewContentReplace"
|
||||
@append-paragraph="onPreviewAppendParagraph"
|
||||
@insert-after-line="onPreviewInsertAfterLine"
|
||||
@delete-line="onPreviewDeleteLine"
|
||||
|
||||
@@ -111,6 +111,7 @@ const onBodyInput = (payload) => {
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
preserve-empty-line-on-full-delete
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="bodyLines.length"
|
||||
:model-value="modelValue"
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
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({
|
||||
@@ -88,6 +94,16 @@ const props = defineProps({
|
||||
plainText: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 전체 선택 삭제 시 비어 있는 원본 줄을 보존할지 여부 */
|
||||
preserveEmptyLineOnFullDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 전체 선택 삭제 후 남길 빈 원본 줄 */
|
||||
emptyMarkdownLine: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -111,11 +127,23 @@ const rootRef = ref(null)
|
||||
const isFocused = ref(false)
|
||||
const suppressBlurCommit = ref(false)
|
||||
const splitLock = ref(false)
|
||||
/** 조합 중 Enter 후 compositionend에서 분리할지 */
|
||||
const pendingSplitAfterComposition = 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') {
|
||||
@@ -140,10 +168,13 @@ const plainTextToEditorText = (value) => String(value ?? '')
|
||||
|
||||
/**
|
||||
* 편집 영역 HTML을 동기화한다.
|
||||
* @param {{ force?: boolean }} [options] - 포커스 중에도 강제 동기화할지
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncEditorHtml = () => {
|
||||
if (!rootRef.value || isFocused.value) {
|
||||
const syncEditorHtml = (options = {}) => {
|
||||
const force = options.force === true
|
||||
|
||||
if (!rootRef.value || (isFocused.value && !force)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -160,22 +191,6 @@ const syncEditorHtml = () => {
|
||||
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()
|
||||
})
|
||||
@@ -247,9 +262,44 @@ const readEditorValue = () => {
|
||||
return rootRef.value.textContent ?? ''
|
||||
}
|
||||
|
||||
return readEditableTextFromElement(rootRef.value)
|
||||
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
|
||||
|
||||
@@ -257,7 +307,20 @@ const onBlur = () => {
|
||||
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
|
||||
@@ -425,6 +488,48 @@ const insertTextAtSelection = (text) => {
|
||||
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}
|
||||
@@ -447,7 +552,7 @@ const isCaretAtLogicalStart = () => {
|
||||
* 커서가 줄의 논리적 맨 끝인지 확인한다.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isCaretAtLogicalEnd = () => isCaretAtEdge('end')
|
||||
const isCaretAtLogicalEnd = () => isCaretAtEdge('end') || getCaretTextOffset() >= readEditorValue().length
|
||||
|
||||
/**
|
||||
* 커서가 위치한 시각 줄 정보를 반환한다.
|
||||
@@ -726,6 +831,22 @@ 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 - 동작
|
||||
@@ -738,6 +859,7 @@ const scheduleEnterAction = (action) => {
|
||||
|
||||
splitLock.value = true
|
||||
suppressBlurCommit.value = true
|
||||
lastStructuralModelSyncAt.value = Date.now()
|
||||
|
||||
if (action === 'split') {
|
||||
splitAtCaret()
|
||||
@@ -749,10 +871,73 @@ const scheduleEnterAction = (action) => {
|
||||
splitLock.value = false
|
||||
window.setTimeout(() => {
|
||||
suppressBlurCommit.value = false
|
||||
}, 180)
|
||||
}, 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}
|
||||
@@ -845,6 +1030,135 @@ const placeCursorAfterPrefix = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 - 키보드 이벤트
|
||||
@@ -857,6 +1171,24 @@ const onKeydown = (event) => {
|
||||
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()
|
||||
@@ -873,6 +1205,7 @@ const onKeydown = (event) => {
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
beginStructuralEdit()
|
||||
emit('delete-line', sourceLine)
|
||||
}
|
||||
|
||||
@@ -895,6 +1228,7 @@ const onKeydown = (event) => {
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
beginStructuralEdit()
|
||||
emit('merge-with-previous', buildInsertBelowPayload())
|
||||
return
|
||||
}
|
||||
@@ -919,6 +1253,7 @@ const onKeydown = (event) => {
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
beginStructuralEdit()
|
||||
emit('delete-line', sourceLine)
|
||||
return
|
||||
}
|
||||
@@ -968,7 +1303,7 @@ const onKeydown = (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasCommandModifier && !event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
if (!hasCommandModifier && !event.altKey && !event.shiftKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
@@ -993,14 +1328,14 @@ const onKeydown = (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
|
||||
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.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
|
||||
if (!hasCommandModifier && !event.altKey && !event.shiftKey && event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
navigateToAdjacentBlock(1, 0, 'block-start')
|
||||
@@ -1008,8 +1343,11 @@ const onKeydown = (event) => {
|
||||
}
|
||||
|
||||
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 && parseSlashInput(readEditorValue())) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isComposingEnter && parseSlashInput(readEditorValue())) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation?.()
|
||||
@@ -1020,77 +1358,83 @@ const onKeydown = (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
|
||||
if (event.key === 'Enter' && enterAction) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
pendingSplitAfterComposition.value = true
|
||||
if (isComposingEnter) {
|
||||
pendingComposedEnterAction.value = enterAction
|
||||
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()
|
||||
pendingComposedEnterAction.value = null
|
||||
runEnterAction(enterAction)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 등 IME 조합 종료 후 Enter 처리
|
||||
* 한글 등 IME 조합 시작 상태를 기록한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionStart = () => {
|
||||
isComposingText.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 등 IME 조합 종료 후 저장된 Enter 동작을 실행한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionEnd = () => {
|
||||
if (!pendingSplitAfterComposition.value) {
|
||||
return
|
||||
}
|
||||
isComposingText.value = false
|
||||
lastCompositionEndAt.value = Date.now()
|
||||
|
||||
pendingSplitAfterComposition.value = false
|
||||
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
||||
const action = pendingComposedEnterAction.value
|
||||
pendingComposedEnterAction.value = null
|
||||
|
||||
if (enterMode !== 'split-paragraph' && enterMode !== 'insert-below') {
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
suppressNextComposedEnterGlobally()
|
||||
scheduleEnterAction(enterMode === 'split-paragraph' ? 'split' : 'insert-below')
|
||||
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 - 커서 위치
|
||||
@@ -1123,6 +1467,7 @@ defineExpose({ focusEditor, readEditorValue })
|
||||
]"
|
||||
: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"
|
||||
@@ -1130,6 +1475,8 @@ defineExpose({ focusEditor, readEditorValue })
|
||||
@input="onEditorInput"
|
||||
@paste="onPaste"
|
||||
@keydown="onKeydown"
|
||||
@keyup="onKeyup"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionend="onCompositionEnd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
parseImageMarkdownLine
|
||||
} from '../../lib/markdown-image.js'
|
||||
import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
|
||||
import {
|
||||
applyLiveSelectionDelete,
|
||||
collapseLiveSelection,
|
||||
extendSelectionAcrossBlocks,
|
||||
getSelectableEditableElements,
|
||||
isLiveSelectionDeleteKey,
|
||||
LIVE_SELECTION_BRIDGE_KEY,
|
||||
selectAllEditableElements
|
||||
} from '../../lib/markdown-live-selection.js'
|
||||
import {
|
||||
appendTextToMarkdownLine,
|
||||
getAppendTextForMerge,
|
||||
@@ -69,7 +78,8 @@ const emit = defineEmits([
|
||||
'line-blur',
|
||||
'slash-update',
|
||||
'slash-end',
|
||||
'slash-apply'
|
||||
'slash-apply',
|
||||
'content-replace'
|
||||
])
|
||||
|
||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
@@ -903,9 +913,29 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
const line = getMarkdownLine(lineIndex)
|
||||
const isBlankMarker = /^>\s*$/.test(line) || /^[-*+]\s*$/.test(line) || /^\d+\.\s*$/.test(line.trim())
|
||||
const elementSourceLine = Number(element.getAttribute('data-source-line'))
|
||||
const isRangedLine = Number.isInteger(elementSourceLine) && elementSourceLine !== lineIndex
|
||||
const elementSourceLineEnd = Number(element.getAttribute('data-source-line-end'))
|
||||
const hasSourceLineRange = Number.isInteger(elementSourceLine) && Number.isInteger(elementSourceLineEnd)
|
||||
const spansMultipleLines = hasSourceLineRange && elementSourceLineEnd > elementSourceLine
|
||||
const isWithinSourceLineRange = hasSourceLineRange
|
||||
&& lineIndex >= elementSourceLine
|
||||
&& lineIndex <= elementSourceLineEnd
|
||||
const useMultilineCaret = spansMultipleLines && isWithinSourceLineRange
|
||||
|
||||
if (!isRangedLine && (isBlankMarker || !line.trim())) {
|
||||
/**
|
||||
* 멀티라인 편집 영역에서 원본 줄에 해당하는 텍스트 오프셋을 반환한다.
|
||||
* @returns {number} 루트 기준 오프셋
|
||||
*/
|
||||
const getCaretOffsetForSourceLine = () => {
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
const textLines = text.length ? text.split('\n') : ['']
|
||||
const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1))
|
||||
|
||||
return textLines
|
||||
.slice(0, targetLineIndex)
|
||||
.reduce((sum, textLine) => sum + textLine.length + 1, 0)
|
||||
}
|
||||
|
||||
if (!useMultilineCaret && (isBlankMarker || !line.trim())) {
|
||||
if (element.getAttribute('contenteditable') === 'true') {
|
||||
element.textContent = ''
|
||||
element.innerHTML = ''
|
||||
@@ -920,15 +950,8 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
}
|
||||
|
||||
if (typeof caretOffset === 'number' && caretOffset >= 0) {
|
||||
if (isRangedLine && Number.isInteger(elementSourceLine)) {
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
const textLines = text.length ? text.split('\n') : ['']
|
||||
const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1))
|
||||
const lineOffset = textLines
|
||||
.slice(0, targetLineIndex)
|
||||
.reduce((sum, textLine) => sum + textLine.length + 1, 0)
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), lineOffset + caretOffset)
|
||||
if (useMultilineCaret) {
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), getCaretOffsetForSourceLine() + caretOffset)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
@@ -938,13 +961,11 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
return
|
||||
}
|
||||
|
||||
if (isRangedLine && Number.isInteger(elementSourceLine)) {
|
||||
if (useMultilineCaret) {
|
||||
const lineOffset = getCaretOffsetForSourceLine()
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
const textLines = text.length ? text.split('\n') : ['']
|
||||
const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1))
|
||||
const lineOffset = textLines
|
||||
.slice(0, targetLineIndex)
|
||||
.reduce((sum, textLine) => sum + textLine.length + 1, 0)
|
||||
const lineText = textLines[targetLineIndex] ?? ''
|
||||
const nextOffset = cursorPosition === 'end'
|
||||
? lineOffset + lineText.length
|
||||
@@ -972,8 +993,128 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 확장 대상 컨테이너를 반환한다.
|
||||
* @param {{ navigationScope?: string, sourceLine?: number }} payload - 확장 요청
|
||||
* @returns {HTMLElement|null} 컨테이너
|
||||
*/
|
||||
const resolveSelectionContainer = (payload) => {
|
||||
if (!rendererRootRef.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (payload.navigationScope === 'parent' && typeof payload.sourceLine === 'number') {
|
||||
const current = rendererRootRef.value.querySelector(`[data-source-line="${payload.sourceLine}"]`)
|
||||
return current?.closest('[data-editable-scope]') || rendererRootRef.value
|
||||
}
|
||||
|
||||
return rendererRootRef.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift 선택을 인접 편집 블록으로 확장한다.
|
||||
* @param {{ sourceLine: number, direction: number, column?: number, navigationScope?: string }} payload - 확장 요청
|
||||
* @returns {void}
|
||||
*/
|
||||
const onExtendLiveSelection = (payload) => {
|
||||
if (!props.interactive || typeof payload?.sourceLine !== 'number' || !payload.direction) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = resolveSelectionContainer(payload)
|
||||
|
||||
extendSelectionAcrossBlocks({
|
||||
container,
|
||||
sourceLine: payload.sourceLine,
|
||||
direction: payload.direction,
|
||||
column: payload.column
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 본문의 모든 편집 가능 텍스트를 선택한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectAllLiveDocument = () => {
|
||||
if (!props.interactive || !rendererRootRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const elements = getSelectableEditableElements(rendererRootRef.value)
|
||||
selectAllEditableElements(elements)
|
||||
}
|
||||
|
||||
/**
|
||||
* 교차 블록·전체 선택 삭제를 마크다운에 반영한다.
|
||||
* @returns {boolean} 처리 여부
|
||||
*/
|
||||
const deleteLiveSelection = () => {
|
||||
if (!props.interactive || !rendererRootRef.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextMarkdown = applyLiveSelectionDelete(props.content, rendererRootRef.value)
|
||||
|
||||
if (nextMarkdown === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
emit('content-replace', { value: nextMarkdown })
|
||||
collapseLiveSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 선택 삭제 단축키를 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onRendererSelectionKeydown = (event) => {
|
||||
if (!props.interactive || !isLiveSelectionDeleteKey(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const isCut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'x'
|
||||
|
||||
if (isCut && import.meta.client) {
|
||||
const selectedText = window.getSelection()?.toString() ?? ''
|
||||
|
||||
if (selectedText) {
|
||||
navigator.clipboard?.writeText(selectedText).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleteLiveSelection()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
provide(LIVE_SELECTION_BRIDGE_KEY, {
|
||||
extendSelection: (payload) => {
|
||||
if (props.interactive) {
|
||||
onExtendLiveSelection(payload)
|
||||
}
|
||||
},
|
||||
selectDocument: () => {
|
||||
if (props.interactive) {
|
||||
selectAllLiveDocument()
|
||||
}
|
||||
},
|
||||
deleteSelection: () => {
|
||||
if (props.interactive) {
|
||||
return deleteLiveSelection()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
focusEditableAtLine
|
||||
focusEditableAtLine,
|
||||
selectAllLiveDocument
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1509,6 +1650,7 @@ const applyParagraphShortcutSplit = (block, before, after) => {
|
||||
title: ''
|
||||
}),
|
||||
'',
|
||||
'',
|
||||
':::'
|
||||
])
|
||||
return true
|
||||
@@ -1685,6 +1827,10 @@ const removeGalleryImage = (block, imageIndex) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryBlockKeydown = (event, block) => {
|
||||
if (event.shiftKey && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -1728,14 +1874,14 @@ const onPreviewCardKeydown = (event, block) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
if (!event.shiftKey && event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
moveFromPreviewCardBlock(block, 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
if (!event.shiftKey && event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
moveFromPreviewCardBlock(block, -1)
|
||||
@@ -2622,6 +2768,7 @@ onBeforeUnmount(() => {
|
||||
@mousedown.capture="emitLiveLineFocus"
|
||||
@focusin.capture="emitLiveLineFocus"
|
||||
@focusout.capture="emitLiveLineBlur"
|
||||
@keydown.capture="onRendererSelectionKeydown"
|
||||
>
|
||||
<template v-for="block in blocks" :key="block.id">
|
||||
<div
|
||||
@@ -2667,6 +2814,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -2684,6 +2833,8 @@ onBeforeUnmount(() => {
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
preserve-empty-line-on-full-delete
|
||||
empty-markdown-line="> "
|
||||
:source-line="getQuoteContentStartLine(block)"
|
||||
:source-line-count="getQuoteLineEntries(block).length"
|
||||
@input="onQuoteBlockCommit(block, $event)"
|
||||
@@ -2702,6 +2853,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -2759,6 +2912,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -2845,6 +3000,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -2866,6 +3023,8 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -3092,6 +3251,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
|
||||
@@ -239,7 +239,7 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
<template>
|
||||
<aside class="right-sidebar site-sidebar flex w-full flex-col overflow-hidden border-[var(--site-line)] max-lg:border-l-0 max-lg:border-t max-lg:px-4 max-lg:pb-10 lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:w-[287px] lg:self-start lg:border-l lg:border-t-0 lg:px-0">
|
||||
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-5 sm:pr-0 max-lg:px-0">
|
||||
<div class="right-sidebar__profile flex items-center gap-3">
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
<img
|
||||
@@ -261,7 +261,7 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="followLinks.length" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div v-if="followLinks.length" class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0">
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
Follow
|
||||
@@ -377,7 +377,7 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
</svg>
|
||||
<span
|
||||
v-else-if="item.icon === 'custom' && item.iconSvg"
|
||||
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center"
|
||||
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center fill-[var(--site-text)]"
|
||||
aria-hidden="true"
|
||||
v-html="item.iconSvg"
|
||||
/>
|
||||
@@ -440,7 +440,7 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
|
||||
<div
|
||||
v-else-if="recommendedSites.length"
|
||||
class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0"
|
||||
class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0"
|
||||
>
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
|
||||
Reference in New Issue
Block a user