v1.2.1: 블록 설정 패널·이미지 alt 토글 및 포커스 수정

게시물 설정 사이드바 오버레이로 이미지·갤러리·임베드를 편집하고, 파일명 alt 토글과 패널 입력 중 닫힘 문제를 해결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-15 18:22:30 +09:00
parent 14ce897bf8
commit 47620ab24c
12 changed files with 821 additions and 201 deletions

View File

@@ -1,5 +1,7 @@
<script setup>
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
import { serializeImageMarkdown } from '../../lib/markdown-image.js'
const props = defineProps({
modelValue: {
@@ -18,7 +20,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'block-panel'])
const activeMode = defineModel('editorMode', {
type: String,
default: 'write'
@@ -49,83 +51,62 @@ const markdownValue = computed({
set: (value) => emit('update:modelValue', value)
})
/** textarea 포커스·블록 패널 상호작용 */
const isTextareaFocused = ref(false)
const isBlockPanelEngaged = ref(false)
let blockPanelFocusTimer = null
/**
* 이미지 마크다운 한 줄을 구조화한다.
* @param {string} line - 이미지 마크다운 줄
* @returns {{ alt: string, url: string, width: string }|null} 이미지 정보
* 현재 포커스가 블록 설정 패널 안에 있는지 확인한다.
* @returns {boolean}
*/
const parseImageMarkdownLine = (line) => {
const match = line.trim().match(/^!\[(.*?)\]\((.*?)\)(?:\{width=(regular|wide|full)\})?$/)
const isFocusInBlockPanel = () => Boolean(
typeof document !== 'undefined'
&& document.activeElement?.closest?.('.admin-editor-block-panel')
)
if (!match) {
return null
}
return {
alt: match[1] || '',
url: match[2] || '',
width: match[3] || 'regular'
}
/**
* 블록 패널 편집 중 상태를 유지한다.
* @returns {void}
*/
const ensureBlockPanelEngaged = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = true
syncBlockPanelState()
}
/**
* 현재 커서가 속한 이미지 또는 갤러리 블록 정보를 찾는다.
* @returns {{ type: 'image'|'gallery', startLine: number, endLine: number, images: Array<{ alt: string, url: string, width: string }> }|null}
* 커서 줄 기준 활성 블록(이미지·갤러리·임베드)
* @returns {Object|null}
*/
const activeMediaBlock = computed(() => {
const lines = (markdownValue.value || '').split('\n')
const currentLine = Math.min(activeLogicalLineIndex.value, lines.length - 1)
const activeImage = parseImageMarkdownLine(lines[currentLine] || '')
const activeBlockContext = computed(() => resolveActiveBlockContext(
markdownValue.value,
activeLogicalLineIndex.value
))
if (activeImage) {
return {
type: 'image',
startLine: currentLine,
endLine: currentLine,
images: [activeImage]
}
}
/** @deprecated 내부 호환 alias */
const activeMediaBlock = activeBlockContext
let galleryStart = -1
/**
* 블록 설정 패널 표시 여부
* @returns {boolean}
*/
const isBlockPanelVisible = computed(() => activeMode.value === 'write'
&& Boolean(activeBlockContext.value)
&& (isTextareaFocused.value || isBlockPanelEngaged.value))
for (let index = currentLine; index >= 0; index -= 1) {
if ((lines[index] || '').trim() === ':::gallery') {
galleryStart = index
break
}
/**
* 부모(글 설정 사이드바 오버레이)에 패널 상태를 전달한다.
* @returns {void}
*/
const syncBlockPanelState = () => {
emit('block-panel', {
open: isBlockPanelVisible.value,
panel: activeBlockContext.value
})
}
if ((lines[index] || '').trim() === ':::') {
break
}
}
if (galleryStart === -1) {
return null
}
let galleryEnd = -1
for (let index = galleryStart + 1; index < lines.length; index += 1) {
if ((lines[index] || '').trim() === ':::') {
galleryEnd = index
break
}
}
if (galleryEnd === -1 || currentLine > galleryEnd) {
return null
}
return {
type: 'gallery',
startLine: galleryStart,
endLine: galleryEnd,
images: lines
.slice(galleryStart + 1, galleryEnd)
.map(parseImageMarkdownLine)
.filter(Boolean)
}
})
watch([isBlockPanelVisible, activeBlockContext], syncBlockPanelState, { deep: true })
/**
* 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
@@ -151,6 +132,59 @@ const syncGutterScroll = () => {
}
}
/**
* textarea 포커스 진입
* @returns {void}
*/
const onTextareaFocus = () => {
window.clearTimeout(blockPanelFocusTimer)
isTextareaFocused.value = true
refreshCaretLogicalLine()
}
/**
* textarea 포커스 이탈(블록 패널으로 이동 시 유지)
* @returns {void}
*/
const onTextareaBlur = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (isBlockPanelEngaged.value || isFocusInBlockPanel()) {
return
}
isTextareaFocused.value = false
syncBlockPanelState()
}, 0)
}
/**
* 블록 패널 포커스 진입(AdminPostForm 오버레이에서 호출)
* @returns {void}
*/
const handleBlockPanelFocusIn = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = true
syncBlockPanelState()
}
/**
* 블록 패널 포커스 이탈
* @returns {void}
*/
const handleBlockPanelFocusOut = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (isFocusInBlockPanel()) {
return
}
isBlockPanelEngaged.value = false
if (!isTextareaFocused.value) {
syncBlockPanelState()
}
}, 50)
}
/**
* 커서 위치 기준으로 활성 논리 줄 인덱스를 갱신하고 거터 스크롤을 맞춘다.
* @returns {void}
@@ -174,11 +208,12 @@ const refreshCaretLogicalLine = () => {
}
activeLogicalLineIndex.value = Math.max(0, lineIndex)
syncGutterScroll()
syncBlockPanelState()
})
}
/**
* textarea 스크롤 시 선택 위치를 기억하고 거터를 동기화한다.
* textarea 스크롤 시 선택 위치를 기억하고 거터 스크롤을 맞춘다.
* @returns {void}
*/
const onTextareaScroll = () => {
@@ -234,6 +269,11 @@ const restoreTextareaFocus = () => {
}
watch(() => props.modelValue, () => {
if (isBlockPanelEngaged.value) {
syncBlockPanelState()
return
}
refreshCaretLogicalLine()
})
@@ -320,11 +360,13 @@ onMounted(() => {
document.addEventListener('keydown', onDocumentKeydown)
onBeforeUnmount(() => {
window.clearTimeout(blockPanelFocusTimer)
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('keydown', onDocumentKeydown)
})
refreshCaretLogicalLine()
syncBlockPanelState()
})
/**
@@ -342,12 +384,6 @@ const focusFirstBlock = () => {
})
}
defineExpose({
focusFirstBlock,
toggleEditorMode,
activeMode
})
/**
* textarea 선택 영역 정보를 반환한다.
* @returns {{ start: number, end: number, value: string }} 선택 정보
@@ -514,7 +550,7 @@ const insertCodeBlock = () => {
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
* @returns {string} 이미지 마크다운
*/
const createImageMarkdown = (image) => `![${image.alt || ''}](${image.url})`
const createImageMarkdown = (image) => serializeImageMarkdown(image)
/**
* 지정 줄 범위를 새 줄 목록으로 교체한다.
@@ -551,7 +587,7 @@ const replaceActiveMediaImages = (images) => {
return
}
if (block.type === 'image') {
if (block.kind === 'image') {
if (!images[0]) {
replaceLineRange(block.startLine, block.endLine, [], false)
return
@@ -586,10 +622,21 @@ const updateActiveMediaImage = (imageIndex, patch) => {
return
}
ensureBlockPanelEngaged()
const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image)
replaceActiveMediaImages(images)
}
/**
* 현재 미디어 이미지의 파일명 대체 텍스트 사용 여부를 바꾼다.
* @param {number} imageIndex - 이미지 인덱스
* @param {boolean} enabled - 파일명 사용 여부
* @returns {void}
*/
const setActiveMediaUseAlt = (imageIndex, enabled) => {
updateActiveMediaImage(imageIndex, { useAlt: enabled })
}
/**
* 현재 갤러리 이미지 순서를 바꾼다.
* @param {number} imageIndex - 이동할 이미지 인덱스
@@ -599,7 +646,7 @@ const updateActiveMediaImage = (imageIndex, patch) => {
const moveActiveGalleryImage = (imageIndex, direction) => {
const block = activeMediaBlock.value
if (!block || block.type !== 'gallery') {
if (!block || block.kind !== 'gallery') {
return
}
@@ -638,7 +685,7 @@ const removeActiveMediaImage = (imageIndex) => {
const appendImagesToActiveGallery = (images) => {
const block = activeMediaBlock.value
if (!block || block.type !== 'gallery') {
if (!block || block.kind !== 'gallery') {
insertGallery(images)
return
}
@@ -646,6 +693,33 @@ const appendImagesToActiveGallery = (images) => {
replaceActiveMediaImages([...block.images, ...images])
}
/**
* 현재 임베드 URL을 수정한다.
* @param {string} url - 임베드 URL
* @returns {void}
*/
const updateActiveEmbedUrl = (url) => {
const block = activeBlockContext.value
if (!block || block.kind !== 'embed') {
return
}
ensureBlockPanelEngaged()
const trimmed = String(url || '').trim()
if (!trimmed) {
replaceLineRange(block.startLine, block.endLine, [], false)
return
}
replaceLineRange(block.startLine, block.endLine, [
':::embed',
trimmed,
':::'
], false)
}
/**
* 이미지 마크다운을 삽입한다.
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
@@ -696,6 +770,21 @@ const openMediaPicker = async (target) => {
await fetchMediaItems()
}
defineExpose({
focusFirstBlock,
toggleEditorMode,
activeMode,
handleBlockPanelFocusIn,
handleBlockPanelFocusOut,
updateActiveMediaImage,
setActiveMediaUseAlt,
moveActiveGalleryImage,
removeActiveMediaImage,
appendImagesToActiveGallery,
updateActiveEmbedUrl,
openMediaPicker
})
/**
* 미디어 선택 창을 닫는다.
* @returns {void}
@@ -766,18 +855,21 @@ const applyMediaSelection = () => {
if (item) {
insertImage({
url: item.url,
alt: item.name || ''
useAlt: false,
caption: ''
})
}
} else if (mediaPickerTarget.value === 'active-gallery') {
appendImagesToActiveGallery(selectedItems.map((item) => ({
url: item.url,
alt: item.name || ''
useAlt: false,
caption: ''
})))
} else {
insertGallery(selectedItems.map((item) => ({
url: item.url,
alt: item.name || ''
useAlt: false,
caption: ''
})))
}
@@ -820,7 +912,8 @@ const uploadAndInsert = async (files, target = 'image') => {
const uploadedFiles = await uploadImages(files)
const images = uploadedFiles.map((file) => ({
url: file.url,
alt: file.name || ''
useAlt: false,
caption: ''
}))
if (target === 'gallery') {
@@ -1213,94 +1306,10 @@ const handleKeydown = (event) => {
@click="refreshCaretLogicalLine"
@keyup="refreshCaretLogicalLine"
@select="refreshCaretLogicalLine"
@focus="refreshCaretLogicalLine"
@focus="onTextareaFocus"
@blur="onTextareaBlur"
/>
</div>
<section
v-if="activeMediaBlock"
class="admin-markdown-editor__media-editor mt-3 rounded border border-[#e3e6e8] bg-white p-4"
aria-label="현재 미디어 블록 편집"
>
<div class="admin-markdown-editor__media-editor-header flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="admin-markdown-editor__media-editor-title text-sm font-bold text-[#15171a]">
{{ activeMediaBlock.type === 'gallery' ? '현재 갤러리 편집' : '현재 이미지 편집' }}
</h3>
<p class="admin-markdown-editor__media-editor-meta mt-1 text-xs text-[#6b7280]">
{{ activeMediaBlock.type === 'gallery' ? `${activeMediaBlock.images.length}개 이미지` : '커서가 위치한 이미지 줄' }}
</p>
</div>
<button
v-if="activeMediaBlock.type === 'gallery'"
class="admin-markdown-editor__media-editor-add rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white"
type="button"
@click="openMediaPicker('active-gallery')"
>
이미지 추가
</button>
</div>
<div class="admin-markdown-editor__media-editor-list mt-4 grid gap-3">
<div
v-for="(image, imageIndex) in activeMediaBlock.images"
:key="`media-editor-image-${imageIndex}`"
class="admin-markdown-editor__media-editor-row grid gap-3 rounded border border-[#edf0f2] bg-[#fafafa] p-3 md:grid-cols-[120px_minmax(0,1fr)]"
>
<img
class="admin-markdown-editor__media-editor-thumb aspect-[4/3] w-full rounded bg-[#eff1f2] object-cover"
:src="image.url"
:alt="image.alt || ''"
>
<div class="admin-markdown-editor__media-editor-fields grid gap-2">
<label class="admin-markdown-editor__media-editor-field grid gap-1 text-xs font-semibold text-[#394047]">
대체 텍스트
<input
class="admin-markdown-editor__media-editor-input rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a]"
:value="image.alt"
type="text"
@input="updateActiveMediaImage(imageIndex, { alt: $event.target.value })"
>
</label>
<label class="admin-markdown-editor__media-editor-field grid gap-1 text-xs font-semibold text-[#394047]">
이미지 URL
<input
class="admin-markdown-editor__media-editor-input rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a]"
:value="image.url"
type="text"
@input="updateActiveMediaImage(imageIndex, { url: $event.target.value })"
>
</label>
<div class="admin-markdown-editor__media-editor-actions flex flex-wrap items-center gap-2">
<button
v-if="activeMediaBlock.type === 'gallery'"
class="admin-markdown-editor__media-editor-action rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="imageIndex === 0"
@click="moveActiveGalleryImage(imageIndex, -1)"
>
위로
</button>
<button
v-if="activeMediaBlock.type === 'gallery'"
class="admin-markdown-editor__media-editor-action rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="imageIndex === activeMediaBlock.images.length - 1"
@click="moveActiveGalleryImage(imageIndex, 1)"
>
아래로
</button>
<button
class="admin-markdown-editor__media-editor-remove rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
type="button"
@click="removeActiveMediaImage(imageIndex)"
>
삭제
</button>
</div>
</div>
</div>
</div>
</section>
<div v-if="isUploading" class="admin-markdown-editor__uploading absolute right-3 top-3 rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white">
업로드
</div>
@@ -1376,7 +1385,7 @@ const handleKeydown = (event) => {
v-for="item in filteredMediaItems"
:key="item.url"
class="admin-markdown-editor__media-item group overflow-hidden rounded border bg-white text-left transition"
:class="selectedMediaUrls.includes(item.url) ? 'border-[#15171a] ring-2 ring-[#15171a]/20' : 'border-[#e3e6e8] hover:border-[#8e9cac]'"
:class="selectedMediaUrls.includes(item.url) ? 'border-[3px] border-[#ff7a00] ring-2 ring-[#ff7a00]/40' : 'border border-[#e3e6e8] hover:border-[#8e9cac]'"
type="button"
@click="toggleMediaSelection(item)"
>