|
|
|
|
@@ -35,12 +35,7 @@ const isKeyboardPriorityMode = ref(false)
|
|
|
|
|
const calloutEmojiPickerBlockId = ref('')
|
|
|
|
|
const calloutColorPopoverBlockId = ref('')
|
|
|
|
|
const calloutEmojiComposingBlockId = ref('')
|
|
|
|
|
const editorFlashMessage = ref('')
|
|
|
|
|
const blockRangeSelection = ref(null)
|
|
|
|
|
const isBlockRangeDragging = ref(false)
|
|
|
|
|
const blockEditorRootRef = ref(null)
|
|
|
|
|
let blockIdSeed = 0
|
|
|
|
|
let editorFlashTimer = null
|
|
|
|
|
|
|
|
|
|
const imageWidthOptions = [
|
|
|
|
|
{ value: 'regular', label: '기본' },
|
|
|
|
|
@@ -373,12 +368,11 @@ const serializeImage = (image) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 주어진 블록 배열을 저장용 마크다운으로 변환한다.
|
|
|
|
|
* @param {Array<Object>} blocks - 직렬화할 블록 목록
|
|
|
|
|
* 에디터 블록 목록을 저장용 마크다운으로 변환
|
|
|
|
|
* @returns {string} 마크다운 문자열
|
|
|
|
|
*/
|
|
|
|
|
const serializeBlockArray = (blocks) => {
|
|
|
|
|
const lines = blocks
|
|
|
|
|
const serializeBlocks = () => {
|
|
|
|
|
const lines = editorBlocks.value
|
|
|
|
|
.map((block, index) => {
|
|
|
|
|
const rawText = block.text || ''
|
|
|
|
|
const text = rawText.trim()
|
|
|
|
|
@@ -432,7 +426,7 @@ const serializeBlockArray = (blocks) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!text && block.type === 'paragraph') {
|
|
|
|
|
if (index === blocks.length - 1) {
|
|
|
|
|
if (index === editorBlocks.value.length - 1) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -477,29 +471,6 @@ const serializeBlockArray = (blocks) => {
|
|
|
|
|
}, '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 에디터 블록 인덱스 구간을 마크다운으로 직렬화한다.
|
|
|
|
|
* @param {number} startIdx - 시작 인덱스
|
|
|
|
|
* @param {number} endIdx - 끝 인덱스(포함)
|
|
|
|
|
* @returns {string} 마크다운 문자열
|
|
|
|
|
*/
|
|
|
|
|
const serializeBlockIndexSlice = (startIdx, endIdx) => {
|
|
|
|
|
const lo = Math.max(0, Math.min(startIdx, endIdx))
|
|
|
|
|
const hi = Math.min(editorBlocks.value.length - 1, Math.max(startIdx, endIdx))
|
|
|
|
|
|
|
|
|
|
if (lo > hi) {
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return serializeBlockArray(editorBlocks.value.slice(lo, hi + 1))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 에디터 블록 목록을 저장용 마크다운으로 변환
|
|
|
|
|
* @returns {string} 마크다운 문자열
|
|
|
|
|
*/
|
|
|
|
|
const serializeBlocks = () => serializeBlockArray(editorBlocks.value)
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 부모 폼으로 콘텐츠 변경 전달
|
|
|
|
|
* @returns {void}
|
|
|
|
|
@@ -761,230 +732,6 @@ const syncTextBlockFromDom = (index) => {
|
|
|
|
|
return block
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 에디터 상단에 잠깐 안내 문구를 표시한다.
|
|
|
|
|
* @param {string} message - 표시할 문구
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const flashEditorMessage = (message) => {
|
|
|
|
|
editorFlashMessage.value = message
|
|
|
|
|
|
|
|
|
|
if (import.meta.client && editorFlashTimer) {
|
|
|
|
|
window.clearTimeout(editorFlashTimer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (import.meta.client) {
|
|
|
|
|
editorFlashTimer = window.setTimeout(() => {
|
|
|
|
|
editorFlashMessage.value = ''
|
|
|
|
|
editorFlashTimer = null
|
|
|
|
|
}, 2600)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 클립보드 텍스트를 여러 블록으로 나누어 붙일지 판별한다.
|
|
|
|
|
* @param {string} text - 순수 텍스트
|
|
|
|
|
* @param {FileList|undefined} files - 첨부 파일
|
|
|
|
|
* @returns {boolean} 구조화 붙여넣기 여부
|
|
|
|
|
*/
|
|
|
|
|
const shouldParseClipboardAsBlocks = (text, files) => {
|
|
|
|
|
if (files && files.length > 0) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!text || !String(text).trim()) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (text.includes('\n')) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const head = text.trimStart().slice(0, 120)
|
|
|
|
|
|
|
|
|
|
if (/^#{1,3}\s/m.test(head) || /^>\s/m.test(head) || /^- /m.test(head) || /^```/m.test(head) || /^:::+/m.test(head)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (/\[[^\]]+\]\([^)]+\)/.test(head)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* contenteditable 요소 안에서 선택 구간의 순수 텍스트 기준 오프셋을 계산한다.
|
|
|
|
|
* @param {HTMLElement} element - 편집 루트 요소
|
|
|
|
|
* @returns {{ start: number, end: number }} 시작·끝 오프셋
|
|
|
|
|
*/
|
|
|
|
|
const getPlainTextOffsetsWithinElement = (element) => {
|
|
|
|
|
const selection = window.getSelection()
|
|
|
|
|
|
|
|
|
|
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) {
|
|
|
|
|
const len = element.textContent?.length || 0
|
|
|
|
|
|
|
|
|
|
return { start: len, end: len }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const range = selection.getRangeAt(0)
|
|
|
|
|
const startRange = document.createRange()
|
|
|
|
|
|
|
|
|
|
startRange.selectNodeContents(element)
|
|
|
|
|
startRange.setEnd(range.startContainer, range.startOffset)
|
|
|
|
|
const start = startRange.toString().length
|
|
|
|
|
const endRange = document.createRange()
|
|
|
|
|
|
|
|
|
|
endRange.selectNodeContents(element)
|
|
|
|
|
endRange.setEnd(range.endContainer, range.endOffset)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
start,
|
|
|
|
|
end: endRange.toString().length
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 붙여넣기용 블록 목록에 새 id를 부여한다.
|
|
|
|
|
* @param {Array<Object>} blocks - 파싱된 블록
|
|
|
|
|
* @returns {Array<Object>} id가 갱신된 블록
|
|
|
|
|
*/
|
|
|
|
|
const cloneBlocksWithNewIds = (blocks) => blocks.map((block) => ({
|
|
|
|
|
...block,
|
|
|
|
|
id: `editor-block-${(blockIdSeed += 1)}`
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 텍스트를 앞뒤 잘라낸 뒤 붙인 블록으로 현재 블록을 대체한다.
|
|
|
|
|
* @param {number} index - 대상 블록 인덱스
|
|
|
|
|
* @param {string} clipboardText - 클립보드 순수 텍스트
|
|
|
|
|
* @param {string} before - 커서 앞에 남길 텍스트
|
|
|
|
|
* @param {string} after - 커서 뒤에 남길 텍스트
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const applyStructuredPaste = (index, clipboardText, before, after) => {
|
|
|
|
|
let inserted = parseMarkdownToBlocks(clipboardText)
|
|
|
|
|
|
|
|
|
|
inserted = cloneBlocksWithNewIds(inserted)
|
|
|
|
|
|
|
|
|
|
if (!inserted.length) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mergeableTypes = ['paragraph', 'heading', 'quote', 'list', 'code', 'callout']
|
|
|
|
|
const first = inserted[0]
|
|
|
|
|
|
|
|
|
|
if (before) {
|
|
|
|
|
if (mergeableTypes.includes(first.type)) {
|
|
|
|
|
first.text = `${before}${first.text || ''}`
|
|
|
|
|
} else {
|
|
|
|
|
inserted = [createEditorBlock('paragraph', before), ...inserted]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const last = inserted[inserted.length - 1]
|
|
|
|
|
|
|
|
|
|
if (after) {
|
|
|
|
|
if (mergeableTypes.includes(last.type)) {
|
|
|
|
|
last.text = `${last.text || ''}${after}`
|
|
|
|
|
} else {
|
|
|
|
|
inserted = [...inserted, createEditorBlock('paragraph', after)]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
editorBlocks.value.splice(index, 1, ...inserted)
|
|
|
|
|
normalizeTrailingTextBlock()
|
|
|
|
|
emitContent()
|
|
|
|
|
nextTick(() => focusBlock(index + inserted.length - 1, 'end'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 여러 줄·마크다운 붙여넣기를 블록 단위로 반영한다.
|
|
|
|
|
* @param {ClipboardEvent} event - 붙여넣기 이벤트
|
|
|
|
|
* @param {number} index - 블록 인덱스
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const handleTextBlockPaste = (event, index) => {
|
|
|
|
|
const block = editorBlocks.value[index]
|
|
|
|
|
|
|
|
|
|
if (!block || !isTextBlock(block) || isComposingText.value) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = event.clipboardData
|
|
|
|
|
|
|
|
|
|
if (!data || data.files?.length) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const clip = data.getData('text/plain') || ''
|
|
|
|
|
|
|
|
|
|
if (!shouldParseClipboardAsBlocks(clip, data.files)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
const element = blockRefs.value[index]
|
|
|
|
|
|
|
|
|
|
if (!element) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { start, end } = getPlainTextOffsetsWithinElement(element)
|
|
|
|
|
const domText = getTextBlockDomText(index)
|
|
|
|
|
const before = domText.slice(0, start)
|
|
|
|
|
const after = domText.slice(end)
|
|
|
|
|
|
|
|
|
|
applyStructuredPaste(index, clip, before, after)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 블록마다 분리된 contenteditable에서는 문서 전체 선택이 불가하므로, Cmd/Ctrl+A 시 마크다운을 클립보드에 담는다. 블록 범위 선택 중이면 해당 구간만 복사한다.
|
|
|
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
|
|
|
* @param {number} index - 포커스 블록 인덱스
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
*/
|
|
|
|
|
const handleTextBlockSelectAll = async (event, index) => {
|
|
|
|
|
if (!(event.metaKey || event.ctrlKey) || event.shiftKey || String(event.key).toLowerCase() !== 'a') {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
const rangeSel = blockRangeSelection.value
|
|
|
|
|
const md = rangeSel
|
|
|
|
|
? serializeBlockIndexSlice(rangeSel.anchor, rangeSel.focus)
|
|
|
|
|
: serializeBlocks()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (navigator.clipboard?.writeText) {
|
|
|
|
|
await navigator.clipboard.writeText(md)
|
|
|
|
|
|
|
|
|
|
if (rangeSel) {
|
|
|
|
|
flashEditorMessage('선택한 블록 구간(마크다운)을 클립보드에 복사했습니다.')
|
|
|
|
|
} else {
|
|
|
|
|
flashEditorMessage('전체 본문(마크다운)을 클립보드에 복사했습니다. 다른 편집기에 Cmd+V로 붙여넣을 수 있습니다.')
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
flashEditorMessage('이 브라우저에서는 클립보드 API를 사용할 수 없습니다.')
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
flashEditorMessage('클립보드 복사에 실패했습니다. 주소가 https인지·사이트 권한을 확인해 주세요.')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nextTick(() => focusBlock(index, 'end'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 텍스트 블록 공통 키다운(전체 복사 단축키 등)
|
|
|
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
|
|
|
* @param {number} index - 블록 인덱스
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
*/
|
|
|
|
|
const handleTextBlockKeydownRoot = async (event, index) => {
|
|
|
|
|
await handleTextBlockSelectAll(event, index)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 블록 타입에 맞는 태그명 반환
|
|
|
|
|
* @param {Object} block - 에디터 블록
|
|
|
|
|
@@ -1240,7 +987,6 @@ const activeCalloutBlock = computed(() => {
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const applyCommand = (command) => {
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
const index = activeBlockIndex.value
|
|
|
|
|
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
@@ -1665,252 +1411,6 @@ const scrollHighlightedCommandIntoView = () => {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 블록 단위 범위 선택을 해제한다.
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const clearBlockRangeSelection = () => {
|
|
|
|
|
blockRangeSelection.value = null
|
|
|
|
|
isBlockRangeDragging.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 블록 인덱스가 범위 선택에 포함되는지 여부
|
|
|
|
|
* @param {number} index - 블록 인덱스
|
|
|
|
|
* @returns {boolean} 포함 여부
|
|
|
|
|
*/
|
|
|
|
|
const isBlockRangeRowSelected = (index) => {
|
|
|
|
|
const sel = blockRangeSelection.value
|
|
|
|
|
|
|
|
|
|
if (!sel) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lo = Math.min(sel.anchor, sel.focus)
|
|
|
|
|
const hi = Math.max(sel.anchor, sel.focus)
|
|
|
|
|
|
|
|
|
|
return index >= lo && index <= hi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 포인터 좌표에 해당하는 행 블록 인덱스를 찾는다.
|
|
|
|
|
* elementFromPoint가 행 밖 여백(블록 간 margin 등)을 가리키면 세로 거리로 가장 가까운 행을 고른다.
|
|
|
|
|
* @param {number} clientX - 뷰포트 X
|
|
|
|
|
* @param {number} clientY - 뷰포트 Y
|
|
|
|
|
* @returns {number} 인덱스, 없으면 -1
|
|
|
|
|
*/
|
|
|
|
|
const resolveBlockIndexFromPointer = (clientX, clientY) => {
|
|
|
|
|
const root = blockEditorRootRef.value
|
|
|
|
|
|
|
|
|
|
if (!root || typeof document === 'undefined') {
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const topEl = document.elementFromPoint(clientX, clientY)
|
|
|
|
|
const directRow = topEl?.closest?.('[data-editor-block-id]')
|
|
|
|
|
|
|
|
|
|
if (directRow && root.contains(directRow)) {
|
|
|
|
|
const id = directRow.getAttribute('data-editor-block-id')
|
|
|
|
|
const idx = editorBlocks.value.findIndex((b) => b.id === id)
|
|
|
|
|
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
return idx
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const idToIndex = new Map(editorBlocks.value.map((b, i) => [b.id, i]))
|
|
|
|
|
let bestIdx = -1
|
|
|
|
|
let bestDelta = Infinity
|
|
|
|
|
|
|
|
|
|
root.querySelectorAll('[data-editor-block-id]').forEach((row) => {
|
|
|
|
|
const id = row.getAttribute('data-editor-block-id')
|
|
|
|
|
const i = id == null ? -1 : idToIndex.get(id)
|
|
|
|
|
|
|
|
|
|
if (i === undefined || i < 0) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const r = row.getBoundingClientRect()
|
|
|
|
|
let delta = 0
|
|
|
|
|
|
|
|
|
|
if (clientY < r.top) {
|
|
|
|
|
delta = r.top - clientY
|
|
|
|
|
} else if (clientY > r.bottom) {
|
|
|
|
|
delta = clientY - r.bottom
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (delta < bestDelta) {
|
|
|
|
|
bestDelta = delta
|
|
|
|
|
bestIdx = i
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const snapPx = 72
|
|
|
|
|
|
|
|
|
|
if (bestIdx >= 0 && bestDelta <= snapPx) {
|
|
|
|
|
return bestIdx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 범위 선택 레인에서 포인터로 블록 범위 드래그를 시작한다.
|
|
|
|
|
* @param {PointerEvent} event - 포인터 이벤트
|
|
|
|
|
* @param {number} index - 블록 인덱스
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const onBlockRangeLanePointerDown = (event, index) => {
|
|
|
|
|
if (event.button !== 0) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
|
|
|
|
|
if (event.shiftKey) {
|
|
|
|
|
if (blockRangeSelection.value) {
|
|
|
|
|
blockRangeSelection.value = {
|
|
|
|
|
anchor: blockRangeSelection.value.anchor,
|
|
|
|
|
focus: index
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
blockRangeSelection.value = { anchor: index, focus: index }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const anchorIndex = index
|
|
|
|
|
|
|
|
|
|
isBlockRangeDragging.value = true
|
|
|
|
|
blockRangeSelection.value = { anchor: anchorIndex, focus: anchorIndex }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 드래그 중 포인터 위치의 블록으로 범위 끝을 갱신한다.
|
|
|
|
|
* @param {PointerEvent} ev - 포인터 이벤트
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const onMove = (ev) => {
|
|
|
|
|
if (!isBlockRangeDragging.value) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const idx = resolveBlockIndexFromPointer(ev.clientX, ev.clientY)
|
|
|
|
|
|
|
|
|
|
if (idx < 0) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blockRangeSelection.value = {
|
|
|
|
|
anchor: anchorIndex,
|
|
|
|
|
focus: idx
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 드래그 종료 시 문서 리스너를 제거한다.
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const onUp = () => {
|
|
|
|
|
isBlockRangeDragging.value = false
|
|
|
|
|
document.removeEventListener('pointermove', onMove)
|
|
|
|
|
document.removeEventListener('pointerup', onUp)
|
|
|
|
|
document.removeEventListener('pointercancel', onUp)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener('pointermove', onMove, { passive: true })
|
|
|
|
|
document.addEventListener('pointerup', onUp, { passive: true })
|
|
|
|
|
document.addEventListener('pointercancel', onUp, { passive: true })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 블록 범위 선택이 있어도 브라우저 기본 복사를 우선해야 하는지 판별한다.
|
|
|
|
|
* contenteditable 안의 비접힘 선택 또는 textarea/input의 선택 구간이 있으면 true.
|
|
|
|
|
* @returns {boolean} 기본 복사를 그대로 두면 true
|
|
|
|
|
*/
|
|
|
|
|
const shouldDeferBlockRangeCopyToNative = () => {
|
|
|
|
|
if (typeof document === 'undefined') {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const activeEl = document.activeElement
|
|
|
|
|
|
|
|
|
|
if (activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLInputElement) {
|
|
|
|
|
const { selectionStart, selectionEnd } = activeEl
|
|
|
|
|
|
|
|
|
|
if (selectionStart != null && selectionEnd != null && selectionStart !== selectionEnd) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sel = window.getSelection()
|
|
|
|
|
|
|
|
|
|
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const range = sel.getRangeAt(0)
|
|
|
|
|
let ancestor = range.commonAncestorContainer
|
|
|
|
|
|
|
|
|
|
if (ancestor.nodeType === Node.TEXT_NODE) {
|
|
|
|
|
ancestor = ancestor.parentElement
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!ancestor || typeof ancestor.closest !== 'function') {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const host = ancestor.closest('[contenteditable="true"]')
|
|
|
|
|
|
|
|
|
|
if (!host) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return blockRefs.value.some((el) => el === host)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 에디터 루트에서 범위 선택이 있을 때 복사 시 마크다운만 클립보드에 넣는다.
|
|
|
|
|
* @param {ClipboardEvent} event - 복사 이벤트
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const handleEditorRootCopy = (event) => {
|
|
|
|
|
if (!blockRangeSelection.value) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shouldDeferBlockRangeCopyToNative()) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { anchor, focus } = blockRangeSelection.value
|
|
|
|
|
const md = serializeBlockIndexSlice(anchor, focus)
|
|
|
|
|
|
|
|
|
|
if (!md) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.clipboardData?.setData('text/plain', md)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 에디터 루트 키다운(Escape로 범위 해제 등)
|
|
|
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const handleEditorRootKeydown = (event) => {
|
|
|
|
|
if (event.key === 'Escape' && blockRangeSelection.value) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 일반 본문 블록 방향키 이동 처리
|
|
|
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
|
|
|
@@ -1926,22 +1426,10 @@ const navigateAcrossBlocks = (event, index, direction) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentElement = blockRefs.value[index]
|
|
|
|
|
|
|
|
|
|
if (!currentElement) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.shiftKey && blockRangeSelection.value) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
const { anchor, focus } = blockRangeSelection.value
|
|
|
|
|
const nextFocus = direction === 'down'
|
|
|
|
|
? Math.min(editorBlocks.value.length - 1, focus + 1)
|
|
|
|
|
: Math.max(0, focus - 1)
|
|
|
|
|
|
|
|
|
|
blockRangeSelection.value = { anchor, focus: nextFocus }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isBoundary = direction === 'up'
|
|
|
|
|
? isCaretOnBoundary(currentElement, 'start')
|
|
|
|
|
: isCaretOnBoundary(currentElement, 'end')
|
|
|
|
|
@@ -1951,21 +1439,12 @@ const navigateAcrossBlocks = (event, index, direction) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextIndex = direction === 'up' ? index - 1 : index + 1
|
|
|
|
|
|
|
|
|
|
if (nextIndex < 0 || nextIndex >= editorBlocks.value.length) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.shiftKey) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
blockRangeSelection.value = { anchor: index, focus: nextIndex }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
const targetBlock = editorBlocks.value[nextIndex]
|
|
|
|
|
|
|
|
|
|
if (isTextBlock(targetBlock)) {
|
|
|
|
|
focusBlock(nextIndex, direction === 'up' ? 'end' : 'start')
|
|
|
|
|
return
|
|
|
|
|
@@ -1982,7 +1461,6 @@ const navigateAcrossBlocks = (event, index, direction) => {
|
|
|
|
|
*/
|
|
|
|
|
const handleEnter = (event, index) => {
|
|
|
|
|
enableKeyboardPriorityMode()
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
|
|
|
|
|
const currentBlock = syncTextBlockFromDom(index)
|
|
|
|
|
|
|
|
|
|
@@ -2073,7 +1551,6 @@ const handleBackspace = (event, index) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
editorBlocks.value.splice(index, 1)
|
|
|
|
|
normalizeTrailingTextBlock()
|
|
|
|
|
emitContent()
|
|
|
|
|
@@ -2127,7 +1604,6 @@ const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
editorBlocks.value.splice(previousBlockIndex, 1)
|
|
|
|
|
normalizeTrailingTextBlock()
|
|
|
|
|
emitContent()
|
|
|
|
|
@@ -2139,7 +1615,6 @@ const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => {
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const selectBlock = (block) => {
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
selectedBlockId.value = block.id
|
|
|
|
|
activeBlockId.value = block.id
|
|
|
|
|
slashQuery.value = ''
|
|
|
|
|
@@ -2159,7 +1634,6 @@ const deleteBlock = (index) => {
|
|
|
|
|
editorBlocks.value.splice(0, 1, createEditorBlock())
|
|
|
|
|
selectedBlockId.value = ''
|
|
|
|
|
activeBlockId.value = editorBlocks.value[0].id
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
emitContent()
|
|
|
|
|
focusBlock(0)
|
|
|
|
|
return
|
|
|
|
|
@@ -2167,7 +1641,6 @@ const deleteBlock = (index) => {
|
|
|
|
|
|
|
|
|
|
editorBlocks.value.splice(index, 1)
|
|
|
|
|
selectedBlockId.value = ''
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
normalizeTrailingTextBlock()
|
|
|
|
|
emitContent()
|
|
|
|
|
focusBlock(Math.min(index, editorBlocks.value.length - 1))
|
|
|
|
|
@@ -2191,7 +1664,6 @@ const deleteSelectedBlock = (event, index) => {
|
|
|
|
|
* @returns {void}
|
|
|
|
|
*/
|
|
|
|
|
const startBlockDrag = (event, block) => {
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
draggingBlockId.value = block.id
|
|
|
|
|
selectedBlockId.value = block.id
|
|
|
|
|
dragTargetIndex.value = -1
|
|
|
|
|
@@ -2249,7 +1721,6 @@ const moveDraggedBlock = (draggedId, targetIndex, targetPosition) => {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1)
|
|
|
|
|
editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock)
|
|
|
|
|
selectedBlockId.value = draggedBlock.id
|
|
|
|
|
@@ -2297,7 +1768,6 @@ const activateBlock = (block) => {
|
|
|
|
|
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
|
|
|
|
|
activeBlockId.value = block.id
|
|
|
|
|
selectedBlockId.value = ''
|
|
|
|
|
clearBlockRangeSelection()
|
|
|
|
|
updateSlashQuery(block)
|
|
|
|
|
updateSlashMenuDirection(index)
|
|
|
|
|
}
|
|
|
|
|
@@ -2476,13 +1946,6 @@ watch(activeCalloutBlock, (nextBlock) => {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (import.meta.client && editorFlashTimer) {
|
|
|
|
|
window.clearTimeout(editorFlashTimer)
|
|
|
|
|
editorFlashTimer = null
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
focusFirstBlock: () => focusBlock(0)
|
|
|
|
|
})
|
|
|
|
|
@@ -2490,20 +1953,10 @@ defineExpose({
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div
|
|
|
|
|
ref="blockEditorRootRef"
|
|
|
|
|
class="admin-block-editor bg-transparent py-4 text-ink"
|
|
|
|
|
:class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
|
|
|
|
|
@mousemove="handleEditorMouseMove"
|
|
|
|
|
@keydown="handleEditorRootKeydown"
|
|
|
|
|
@copy.capture="handleEditorRootCopy"
|
|
|
|
|
>
|
|
|
|
|
<p
|
|
|
|
|
v-if="editorFlashMessage"
|
|
|
|
|
class="admin-block-editor__flash-message mb-3 rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-900"
|
|
|
|
|
role="status"
|
|
|
|
|
>
|
|
|
|
|
{{ editorFlashMessage }}
|
|
|
|
|
</p>
|
|
|
|
|
<div class="admin-block-editor__surface post-prose">
|
|
|
|
|
<div
|
|
|
|
|
v-for="(block, index) in editorBlocks"
|
|
|
|
|
@@ -2511,7 +1964,6 @@ defineExpose({
|
|
|
|
|
class="admin-block-editor__row group/block relative isolate rounded"
|
|
|
|
|
:class="{
|
|
|
|
|
'admin-block-editor__row--selected': selectedBlockId === block.id,
|
|
|
|
|
'admin-block-editor__row--range-selected': isBlockRangeRowSelected(index),
|
|
|
|
|
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
|
|
|
|
|
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
|
|
|
|
|
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
|
|
|
|
|
@@ -2521,7 +1973,6 @@ defineExpose({
|
|
|
|
|
'admin-block-editor__row--structure': !isTextBlock(block)
|
|
|
|
|
}"
|
|
|
|
|
:data-editor-block-id="block.id"
|
|
|
|
|
:data-block-index="index"
|
|
|
|
|
@dragover="updateBlockDropTarget($event, index)"
|
|
|
|
|
@drop="dropBlock($event, index)"
|
|
|
|
|
>
|
|
|
|
|
@@ -2541,16 +1992,6 @@ defineExpose({
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<span
|
|
|
|
|
v-if="block.type !== 'divider'"
|
|
|
|
|
class="admin-block-editor__range-lane absolute bottom-0 left-[-1.375rem] top-0 z-[8] w-[18px] touch-none select-none"
|
|
|
|
|
role="button"
|
|
|
|
|
tabindex="-1"
|
|
|
|
|
aria-label="블록 범위 선택: 드래그 또는 Shift+클릭"
|
|
|
|
|
title="블록 범위 선택: 드래그 또는 Shift+클릭"
|
|
|
|
|
@pointerdown="onBlockRangeLanePointerDown($event, index)"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider border-line">
|
|
|
|
|
|
|
|
|
|
<figure
|
|
|
|
|
@@ -2686,8 +2127,6 @@ defineExpose({
|
|
|
|
|
data-placeholder="콜아웃 텍스트 입력은 이렇게"
|
|
|
|
|
:data-show-placeholder="!block.text"
|
|
|
|
|
@focus="activateBlock(block)"
|
|
|
|
|
@paste="handleTextBlockPaste($event, index)"
|
|
|
|
|
@keydown="handleTextBlockKeydownRoot($event, index)"
|
|
|
|
|
@input="updateBlockText($event, index)"
|
|
|
|
|
@compositionstart="startTextComposition"
|
|
|
|
|
@compositionend="finishTextComposition($event, index)"
|
|
|
|
|
@@ -2845,8 +2284,6 @@ defineExpose({
|
|
|
|
|
:data-placeholder="index === 0 ? '본문을 입력하세요...' : '/ 를 눌러 블록 선택'"
|
|
|
|
|
:data-show-placeholder="shouldShowPlaceholder(block, index)"
|
|
|
|
|
@focus="activateBlock(block)"
|
|
|
|
|
@paste="handleTextBlockPaste($event, index)"
|
|
|
|
|
@keydown="handleTextBlockKeydownRoot($event, index)"
|
|
|
|
|
@input="updateBlockText($event, index)"
|
|
|
|
|
@compositionstart="startTextComposition"
|
|
|
|
|
@compositionend="finishTextComposition($event, index)"
|
|
|
|
|
@@ -3007,27 +2444,6 @@ defineExpose({
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.admin-block-editor__row--callout.admin-block-editor__row--range-selected::before {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.admin-block-editor__row--range-selected::before {
|
|
|
|
|
z-index: -1;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
background: rgba(46, 182, 234, 0.14);
|
|
|
|
|
opacity: 1;
|
|
|
|
|
transform: scaleX(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.admin-block-editor__range-lane {
|
|
|
|
|
cursor: crosshair;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__range-lane {
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
background: rgba(46, 182, 234, 0.08);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.admin-block-editor__row--drop-before::after {
|
|
|
|
|
top: -18px;
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|