관리자 블록 에디터 범위 선택 보완 및 복사 시 네이티브 우선(v1.0.8)
블록 범위가 있어도 contenteditable 비접힘 선택·textarea/input 선택 시 copy 가로채기 생략. 문서·버전 v1.0.8 반영.
This commit is contained in:
@@ -35,7 +35,11 @@ const isKeyboardPriorityMode = ref(false)
|
|||||||
const calloutEmojiPickerBlockId = ref('')
|
const calloutEmojiPickerBlockId = ref('')
|
||||||
const calloutColorPopoverBlockId = ref('')
|
const calloutColorPopoverBlockId = ref('')
|
||||||
const calloutEmojiComposingBlockId = ref('')
|
const calloutEmojiComposingBlockId = ref('')
|
||||||
|
const editorFlashMessage = ref('')
|
||||||
|
const blockRangeSelection = ref(null)
|
||||||
|
const isBlockRangeDragging = ref(false)
|
||||||
let blockIdSeed = 0
|
let blockIdSeed = 0
|
||||||
|
let editorFlashTimer = null
|
||||||
|
|
||||||
const imageWidthOptions = [
|
const imageWidthOptions = [
|
||||||
{ value: 'regular', label: '기본' },
|
{ value: 'regular', label: '기본' },
|
||||||
@@ -368,11 +372,12 @@ const serializeImage = (image) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 에디터 블록 목록을 저장용 마크다운으로 변환
|
* 주어진 블록 배열을 저장용 마크다운으로 변환한다.
|
||||||
|
* @param {Array<Object>} blocks - 직렬화할 블록 목록
|
||||||
* @returns {string} 마크다운 문자열
|
* @returns {string} 마크다운 문자열
|
||||||
*/
|
*/
|
||||||
const serializeBlocks = () => {
|
const serializeBlockArray = (blocks) => {
|
||||||
const lines = editorBlocks.value
|
const lines = blocks
|
||||||
.map((block, index) => {
|
.map((block, index) => {
|
||||||
const rawText = block.text || ''
|
const rawText = block.text || ''
|
||||||
const text = rawText.trim()
|
const text = rawText.trim()
|
||||||
@@ -426,7 +431,7 @@ const serializeBlocks = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!text && block.type === 'paragraph') {
|
if (!text && block.type === 'paragraph') {
|
||||||
if (index === editorBlocks.value.length - 1) {
|
if (index === blocks.length - 1) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +476,29 @@ const serializeBlocks = () => {
|
|||||||
}, '')
|
}, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에디터 블록 인덱스 구간을 마크다운으로 직렬화한다.
|
||||||
|
* @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}
|
* @returns {void}
|
||||||
@@ -732,6 +760,230 @@ const syncTextBlockFromDom = (index) => {
|
|||||||
return block
|
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 - 에디터 블록
|
* @param {Object} block - 에디터 블록
|
||||||
@@ -987,6 +1239,7 @@ const activeCalloutBlock = computed(() => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const applyCommand = (command) => {
|
const applyCommand = (command) => {
|
||||||
|
clearBlockRangeSelection()
|
||||||
const index = activeBlockIndex.value
|
const index = activeBlockIndex.value
|
||||||
|
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
@@ -1411,6 +1664,198 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 범위 선택 레인에서 포인터로 블록 범위 드래그를 시작한다.
|
||||||
|
* @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 el = document.elementFromPoint(ev.clientX, ev.clientY)
|
||||||
|
const row = el?.closest?.('[data-editor-block-id]')
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = row.getAttribute('data-editor-block-id')
|
||||||
|
const idx = editorBlocks.value.findIndex((b) => b.id === id)
|
||||||
|
|
||||||
|
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 - 키보드 이벤트
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
@@ -1426,10 +1871,22 @@ const navigateAcrossBlocks = (event, index, direction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentElement = blockRefs.value[index]
|
const currentElement = blockRefs.value[index]
|
||||||
|
|
||||||
if (!currentElement) {
|
if (!currentElement) {
|
||||||
return
|
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'
|
const isBoundary = direction === 'up'
|
||||||
? isCaretOnBoundary(currentElement, 'start')
|
? isCaretOnBoundary(currentElement, 'start')
|
||||||
: isCaretOnBoundary(currentElement, 'end')
|
: isCaretOnBoundary(currentElement, 'end')
|
||||||
@@ -1439,12 +1896,21 @@ const navigateAcrossBlocks = (event, index, direction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextIndex = direction === 'up' ? index - 1 : index + 1
|
const nextIndex = direction === 'up' ? index - 1 : index + 1
|
||||||
|
|
||||||
if (nextIndex < 0 || nextIndex >= editorBlocks.value.length) {
|
if (nextIndex < 0 || nextIndex >= editorBlocks.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
blockRangeSelection.value = { anchor: index, focus: nextIndex }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBlockRangeSelection()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const targetBlock = editorBlocks.value[nextIndex]
|
const targetBlock = editorBlocks.value[nextIndex]
|
||||||
|
|
||||||
if (isTextBlock(targetBlock)) {
|
if (isTextBlock(targetBlock)) {
|
||||||
focusBlock(nextIndex, direction === 'up' ? 'end' : 'start')
|
focusBlock(nextIndex, direction === 'up' ? 'end' : 'start')
|
||||||
return
|
return
|
||||||
@@ -1461,6 +1927,7 @@ const navigateAcrossBlocks = (event, index, direction) => {
|
|||||||
*/
|
*/
|
||||||
const handleEnter = (event, index) => {
|
const handleEnter = (event, index) => {
|
||||||
enableKeyboardPriorityMode()
|
enableKeyboardPriorityMode()
|
||||||
|
clearBlockRangeSelection()
|
||||||
|
|
||||||
const currentBlock = syncTextBlockFromDom(index)
|
const currentBlock = syncTextBlockFromDom(index)
|
||||||
|
|
||||||
@@ -1551,6 +2018,7 @@ const handleBackspace = (event, index) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
clearBlockRangeSelection()
|
||||||
editorBlocks.value.splice(index, 1)
|
editorBlocks.value.splice(index, 1)
|
||||||
normalizeTrailingTextBlock()
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
@@ -1604,6 +2072,7 @@ const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearBlockRangeSelection()
|
||||||
editorBlocks.value.splice(previousBlockIndex, 1)
|
editorBlocks.value.splice(previousBlockIndex, 1)
|
||||||
normalizeTrailingTextBlock()
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
@@ -1615,6 +2084,7 @@ const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const selectBlock = (block) => {
|
const selectBlock = (block) => {
|
||||||
|
clearBlockRangeSelection()
|
||||||
selectedBlockId.value = block.id
|
selectedBlockId.value = block.id
|
||||||
activeBlockId.value = block.id
|
activeBlockId.value = block.id
|
||||||
slashQuery.value = ''
|
slashQuery.value = ''
|
||||||
@@ -1634,6 +2104,7 @@ const deleteBlock = (index) => {
|
|||||||
editorBlocks.value.splice(0, 1, createEditorBlock())
|
editorBlocks.value.splice(0, 1, createEditorBlock())
|
||||||
selectedBlockId.value = ''
|
selectedBlockId.value = ''
|
||||||
activeBlockId.value = editorBlocks.value[0].id
|
activeBlockId.value = editorBlocks.value[0].id
|
||||||
|
clearBlockRangeSelection()
|
||||||
emitContent()
|
emitContent()
|
||||||
focusBlock(0)
|
focusBlock(0)
|
||||||
return
|
return
|
||||||
@@ -1641,6 +2112,7 @@ const deleteBlock = (index) => {
|
|||||||
|
|
||||||
editorBlocks.value.splice(index, 1)
|
editorBlocks.value.splice(index, 1)
|
||||||
selectedBlockId.value = ''
|
selectedBlockId.value = ''
|
||||||
|
clearBlockRangeSelection()
|
||||||
normalizeTrailingTextBlock()
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
focusBlock(Math.min(index, editorBlocks.value.length - 1))
|
focusBlock(Math.min(index, editorBlocks.value.length - 1))
|
||||||
@@ -1664,6 +2136,7 @@ const deleteSelectedBlock = (event, index) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const startBlockDrag = (event, block) => {
|
const startBlockDrag = (event, block) => {
|
||||||
|
clearBlockRangeSelection()
|
||||||
draggingBlockId.value = block.id
|
draggingBlockId.value = block.id
|
||||||
selectedBlockId.value = block.id
|
selectedBlockId.value = block.id
|
||||||
dragTargetIndex.value = -1
|
dragTargetIndex.value = -1
|
||||||
@@ -1721,6 +2194,7 @@ const moveDraggedBlock = (draggedId, targetIndex, targetPosition) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearBlockRangeSelection()
|
||||||
const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1)
|
const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1)
|
||||||
editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock)
|
editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock)
|
||||||
selectedBlockId.value = draggedBlock.id
|
selectedBlockId.value = draggedBlock.id
|
||||||
@@ -1768,6 +2242,7 @@ const activateBlock = (block) => {
|
|||||||
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
|
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
|
||||||
activeBlockId.value = block.id
|
activeBlockId.value = block.id
|
||||||
selectedBlockId.value = ''
|
selectedBlockId.value = ''
|
||||||
|
clearBlockRangeSelection()
|
||||||
updateSlashQuery(block)
|
updateSlashQuery(block)
|
||||||
updateSlashMenuDirection(index)
|
updateSlashMenuDirection(index)
|
||||||
}
|
}
|
||||||
@@ -1946,6 +2421,13 @@ watch(activeCalloutBlock, (nextBlock) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (import.meta.client && editorFlashTimer) {
|
||||||
|
window.clearTimeout(editorFlashTimer)
|
||||||
|
editorFlashTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focusFirstBlock: () => focusBlock(0)
|
focusFirstBlock: () => focusBlock(0)
|
||||||
})
|
})
|
||||||
@@ -1956,7 +2438,16 @@ defineExpose({
|
|||||||
class="admin-block-editor bg-transparent py-4 text-ink"
|
class="admin-block-editor bg-transparent py-4 text-ink"
|
||||||
:class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
|
:class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
|
||||||
@mousemove="handleEditorMouseMove"
|
@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 class="admin-block-editor__surface post-prose">
|
||||||
<div
|
<div
|
||||||
v-for="(block, index) in editorBlocks"
|
v-for="(block, index) in editorBlocks"
|
||||||
@@ -1964,6 +2455,7 @@ defineExpose({
|
|||||||
class="admin-block-editor__row group/block relative isolate rounded"
|
class="admin-block-editor__row group/block relative isolate rounded"
|
||||||
:class="{
|
:class="{
|
||||||
'admin-block-editor__row--selected': selectedBlockId === block.id,
|
'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--dragging opacity-50': draggingBlockId === block.id,
|
||||||
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
|
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
|
||||||
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
|
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
|
||||||
@@ -1973,6 +2465,7 @@ defineExpose({
|
|||||||
'admin-block-editor__row--structure': !isTextBlock(block)
|
'admin-block-editor__row--structure': !isTextBlock(block)
|
||||||
}"
|
}"
|
||||||
:data-editor-block-id="block.id"
|
:data-editor-block-id="block.id"
|
||||||
|
:data-block-index="index"
|
||||||
@dragover="updateBlockDropTarget($event, index)"
|
@dragover="updateBlockDropTarget($event, index)"
|
||||||
@drop="dropBlock($event, index)"
|
@drop="dropBlock($event, index)"
|
||||||
>
|
>
|
||||||
@@ -1992,6 +2485,16 @@ defineExpose({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="block.type !== 'divider'"
|
||||||
|
class="admin-block-editor__range-lane absolute bottom-0 left-[-1.25rem] top-0 z-[8] w-[14px] 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">
|
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider border-line">
|
||||||
|
|
||||||
<figure
|
<figure
|
||||||
@@ -2127,6 +2630,8 @@ defineExpose({
|
|||||||
data-placeholder="콜아웃 텍스트 입력은 이렇게"
|
data-placeholder="콜아웃 텍스트 입력은 이렇게"
|
||||||
:data-show-placeholder="!block.text"
|
:data-show-placeholder="!block.text"
|
||||||
@focus="activateBlock(block)"
|
@focus="activateBlock(block)"
|
||||||
|
@paste="handleTextBlockPaste($event, index)"
|
||||||
|
@keydown="handleTextBlockKeydownRoot($event, index)"
|
||||||
@input="updateBlockText($event, index)"
|
@input="updateBlockText($event, index)"
|
||||||
@compositionstart="startTextComposition"
|
@compositionstart="startTextComposition"
|
||||||
@compositionend="finishTextComposition($event, index)"
|
@compositionend="finishTextComposition($event, index)"
|
||||||
@@ -2284,6 +2789,8 @@ defineExpose({
|
|||||||
:data-placeholder="index === 0 ? '본문을 입력하세요...' : '/ 를 눌러 블록 선택'"
|
:data-placeholder="index === 0 ? '본문을 입력하세요...' : '/ 를 눌러 블록 선택'"
|
||||||
:data-show-placeholder="shouldShowPlaceholder(block, index)"
|
:data-show-placeholder="shouldShowPlaceholder(block, index)"
|
||||||
@focus="activateBlock(block)"
|
@focus="activateBlock(block)"
|
||||||
|
@paste="handleTextBlockPaste($event, index)"
|
||||||
|
@keydown="handleTextBlockKeydownRoot($event, index)"
|
||||||
@input="updateBlockText($event, index)"
|
@input="updateBlockText($event, index)"
|
||||||
@compositionstart="startTextComposition"
|
@compositionstart="startTextComposition"
|
||||||
@compositionend="finishTextComposition($event, index)"
|
@compositionend="finishTextComposition($event, index)"
|
||||||
@@ -2444,6 +2951,27 @@ defineExpose({
|
|||||||
opacity: 0;
|
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 {
|
.admin-block-editor__row--drop-before::after {
|
||||||
top: -18px;
|
top: -18px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-13 v1.0.8
|
||||||
|
|
||||||
|
### 블록 범위 복사와 부분 텍스트 선택
|
||||||
|
|
||||||
|
블록 인덱스 범위가 잡힌 상태에서 사용자가 한 블록 안에서 드래그로 일부 문자만 선택해 복사하는 경우가 있다. 루트 `copy` 캡처로 항상 구간 마크다운을 넣으면 기대와 어긋나므로, 비접힘 DOM 선택이 해당 행의 `contenteditable` 호스트에 있거나 `textarea`/`input`에 선택 구간이 있으면 `preventDefault`를 하지 않고 네이티브 클립보드 동작을 유지한다.
|
||||||
|
|
||||||
|
## 2026-05-14 v1.0.7
|
||||||
|
|
||||||
|
### 블록 단위 범위 선택과 마크다운 전용 복사
|
||||||
|
|
||||||
|
다중 `contenteditable` 구조에서는 브라우저가 블록 경계를 넘는 선택을 제공하지 않는다. 붙여넣기 분할과 전체 Cmd+A 클립보드 복사만으로는 구간 복사 흐름이 부족하므로, 핸들 옆 좁은 레인에서 포인터 드래그와 Shift 조합으로 블록 인덱스 구간을 잡고, 복사 파이프는 `text/plain` 마크다운으로만 통일했다. 문자 단위 범위는 다음 단계로 남긴다. 범위는 인덱스 쌍이므로 블록 삭제·이동·분할 붙여넣기 등 배열이 바뀌는 경로마다 해제해 stale 상태를 막는다.
|
||||||
|
|
||||||
|
## 2026-05-14 v1.0.6
|
||||||
|
|
||||||
|
### 블록 에디터 붙여넣기·전체 선택 UX
|
||||||
|
|
||||||
|
블록마다 `contenteditable`을 두면 브라우저가 편집 호스트 경계를 넘는 선택을 허용하지 않아 Cmd+A가 한 블록에만 먹고, 여러 줄 마크다운을 붙여넣으면 한 블록 안에 줄바꿈 문자만 들어가 저장 구조와 어긋난다. 완전한 단일 편집면(ProseMirror 등)으로 바꾸지 않는 한, 붙여넣기 경로에서 `parseMarkdownToBlocks`로 분할 삽입하고, Cmd/Ctrl+A는 전체 마크다운을 클립보드에 복사하는 보완으로 실사용 복사·이동 요구를 맞춘다.
|
||||||
|
|
||||||
## 2026-05-14 v1.0.5
|
## 2026-05-14 v1.0.5
|
||||||
|
|
||||||
### Docker 런타임 환경 변수 우선
|
### Docker 런타임 환경 변수 우선
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시, 여러 줄·마크다운 붙여넣기 시 블록 분할, 블록 단위 범위 선택(레인 드래그·Shift+클릭·Shift+↑↓·Escape, 레인 aria-label) 및 선택 구간 마크다운 복사(단 contenteditable 비접힘 선택·textarea/input 선택 시 복사는 네이티브)·범위 있을 때 Cmd/Ctrl+A는 구간만 복사, 블록 삭제·이동·분할 붙여넣기 등 배열 변경 시 범위 자동 해제, 범위 없을 때 Cmd/Ctrl+A는 전체 MD 복사 안내 |
|
||||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||||
|
|||||||
@@ -440,7 +440,10 @@ components/content/
|
|||||||
|
|
||||||
### 관리자 글 편집
|
### 관리자 글 편집
|
||||||
|
|
||||||
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
|
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다. 텍스트 블록마다 별도 `contenteditable`을 쓰므로, 브라우저는 **편집 호스트 경계를 넘는 드래그 선택**을 허용하지 않는다(한 블록 안에서만 연속 선택).
|
||||||
|
- **Cmd/Ctrl+A**(Mac은 Cmd, Windows/Linux는 Ctrl)는 현재 블록만 전체 선택되는 대신, **저장 형식인 전체 본문 마크다운**을 클립보드에 복사하고 짧은 안내 문구를 표시한다. 다른 편집기·파일로 옮길 때 사용한다.
|
||||||
|
- **여러 줄**이거나 제목·인용·목록·펜스 코드·콜아웃/갤러리 등 **마크다운으로 인식되는 한 줄**을 텍스트 블록에 붙여넣으면, 기본 한 블록 삽입 대신 `parseMarkdownToBlocks`로 나눈 **여러 블록**을 현재 커서 위치에 끼워 넣는다. 클립보드에 파일이 있으면 기본 붙여넣기(이미지 등)를 유지한다.
|
||||||
|
- **블록 단위 범위 선택**: 각 행 왼쪽(핸들 오른쪽) **좁은 레인**에서 포인터 드래그로 시작·끝 블록을 지정하거나, **Shift+클릭**으로 끝 블록을 지정한다. 텍스트 블록에서 **Shift+↑/↓**는 경계에 있을 때 범위를 시작하거나, 이미 범위가 있으면 **포커스 쪽 끝 블록 인덱스**를 한 칸씩 늘리거나 줄인다. **Escape**로 범위를 해제한다. 범위가 있을 때 **Cmd/Ctrl+C** 또는 복사(`copy`)는 `text/plain`에 **선택 구간만** 마크다운으로 넣는다. 다만 **한 블록의 contenteditable 안에서 비접힘 텍스트 선택**이 있거나 **textarea/input에 선택 구간**이 있으면 복사는 브라우저 기본 동작(선택된 문자열 등)을 따른다. 범위가 있을 때 **Cmd/Ctrl+A**는 전체가 아니라 **선택 구간** 마크다운을 클립보드에 복사한다. 블록 삭제·드래그 순서 변경·마크다운 분할 붙여넣기 등으로 `editorBlocks` 순서가 바뀌면 범위 선택은 자동으로 해제된다.
|
||||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
||||||
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
||||||
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.0.8
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에서 블록 범위 선택이 있어도, contenteditable 안 비접힘 텍스트 선택 또는 textarea/input 선택 구간이 있으면 복사는 브라우저 기본 동작으로 두고 마크다운 가로채기를 하지 않음.
|
||||||
|
- 패키지 버전 `1.0.8`으로 갱신.
|
||||||
|
|
||||||
|
## v1.0.7
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에 블록 단위 범위 선택 추가(핸들 오른쪽 레인 드래그·Shift+클릭, Shift+↑↓ 확장, Escape 해제).
|
||||||
|
- 범위 선택 시 Cmd/Ctrl+C 및 복사 동작은 `text/plain` 마크다운으로만 반영. 범위가 있을 때 Cmd/Ctrl+A는 선택 구간만 클립보드에 복사.
|
||||||
|
- 블록 삭제·드래그 이동·마크다운 붙여넣기 분할·활성 전환 시 미사용 구조형 블록 제거 등으로 배열이 바뀔 때 범위 선택을 해제해 인덱스 불일치를 방지.
|
||||||
|
- 범위 선택 레인에 `aria-label`·`role="button"` 추가.
|
||||||
|
- 패키지 버전 `1.0.7`으로 갱신.
|
||||||
|
|
||||||
|
## v1.0.6
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에서 여러 줄·마크다운 붙여넣기 시 한 블록에 몰리지 않고 파싱된 여러 블록으로 삽입되도록 처리.
|
||||||
|
- 블록별 contenteditable 한계로 문서 전체 드래그 선택이 불가한 점을 보완하기 위해 Cmd/Ctrl+A로 전체 본문 마크다운을 클립보드에 복사하고 안내 문구를 표시하도록 추가.
|
||||||
|
- 패키지 버전 `1.0.6`으로 갱신.
|
||||||
|
|
||||||
## v1.0.5
|
## v1.0.5
|
||||||
|
|
||||||
- Docker 운영 이미지에서 빌드 시점 `runtimeConfig`가 비어도 컨테이너 런타임 환경 변수(`DATABASE_URL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `MEMBER_SESSION_SECRET`, Resend 설정)를 우선 읽도록 수정.
|
- Docker 운영 이미지에서 빌드 시점 `runtimeConfig`가 비어도 컨테이너 런타임 환경 변수(`DATABASE_URL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `MEMBER_SESSION_SECRET`, Resend 설정)를 우선 읽도록 수정.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.5",
|
"version": "1.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
Reference in New Issue
Block a user