v1.2.1: 블록 설정 패널·이미지 alt 토글 및 포커스 수정
게시물 설정 사이드바 오버레이로 이미지·갤러리·임베드를 편집하고, 파일명 alt 토글과 패널 입력 중 닫힘 문제를 해결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
219
components/admin/AdminEditorBlockPanel.vue
Normal file
219
components/admin/AdminEditorBlockPanel.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 패널 표시 여부 */
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 활성 블록 컨텍스트 */
|
||||
panel: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'panel-focus-in',
|
||||
'panel-focus-out',
|
||||
'update-media-image',
|
||||
'set-media-use-alt',
|
||||
'move-gallery-image',
|
||||
'remove-media-image',
|
||||
'add-gallery-images',
|
||||
'update-embed-url'
|
||||
])
|
||||
|
||||
/**
|
||||
* 블록 종류 라벨
|
||||
* @returns {string}
|
||||
*/
|
||||
const panelTitle = computed(() => {
|
||||
if (!props.panel) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'gallery') {
|
||||
return '갤러리'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return '임베드'
|
||||
}
|
||||
|
||||
return '이미지'
|
||||
})
|
||||
|
||||
/**
|
||||
* 블록 종류 부제
|
||||
* @returns {string}
|
||||
*/
|
||||
const panelMeta = computed(() => {
|
||||
if (!props.panel) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'gallery') {
|
||||
return `${props.panel.images.length}개 이미지`
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return 'YouTube·X 등 URL'
|
||||
}
|
||||
|
||||
return '커서가 위치한 이미지 줄'
|
||||
})
|
||||
|
||||
/**
|
||||
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
|
||||
* @param {FocusEvent} event - focusout 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onPanelFocusOut = (event) => {
|
||||
const root = event.currentTarget
|
||||
const next = event.relatedTarget
|
||||
|
||||
if (next instanceof Node && root.contains(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('panel-focus-out')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="admin-editor-block-panel absolute inset-0 z-20 flex flex-col bg-white shadow-[-8px_0_24px_rgba(15,23,42,0.08)] transition-transform duration-300 ease-out"
|
||||
:class="open ? 'translate-x-0' : 'translate-x-full pointer-events-none'"
|
||||
:aria-hidden="!open"
|
||||
@focusin="emit('panel-focus-in')"
|
||||
@focusout="onPanelFocusOut"
|
||||
>
|
||||
<div v-if="panel" class="admin-editor-block-panel__inner flex h-full flex-col">
|
||||
<header class="admin-editor-block-panel__header flex h-[56px] shrink-0 items-center justify-between border-b border-[#e3e6e8] px-6">
|
||||
<div>
|
||||
<h2 class="admin-editor-block-panel__title text-xl font-bold text-black">
|
||||
{{ panelTitle }}
|
||||
</h2>
|
||||
<p class="admin-editor-block-panel__meta mt-1 text-xs text-[#6b7280]">
|
||||
{{ panelMeta }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="panel.kind === 'gallery'"
|
||||
class="admin-editor-block-panel__add rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white"
|
||||
type="button"
|
||||
@click="emit('add-gallery-images')"
|
||||
>
|
||||
이미지 추가
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-editor-block-panel__body flex-1 overflow-y-auto px-6 py-6">
|
||||
<template v-if="panel.kind === 'embed'">
|
||||
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||
<span class="font-semibold text-[#394047]">임베드 URL</span>
|
||||
<input
|
||||
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
||||
:value="panel.url"
|
||||
type="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
@input="emit('update-embed-url', $event.target.value)"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
YouTube·YouTube Shorts, X(트위터) 게시물 URL을 지원합니다. 그 외 URL은 링크 카드로 표시됩니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="admin-editor-block-panel__media-list grid gap-3">
|
||||
<div
|
||||
v-for="(image, imageIndex) in panel.images"
|
||||
:key="`block-panel-image-${imageIndex}`"
|
||||
class="admin-editor-block-panel__media-row grid gap-3 rounded border border-[#edf0f2] bg-[#fafafa] p-3"
|
||||
>
|
||||
<img
|
||||
class="aspect-[16/10] w-full rounded bg-[#eff1f2] object-cover"
|
||||
:src="image.url"
|
||||
:alt="getImageAltAttribute(image)"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
|
||||
캡션
|
||||
<input
|
||||
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a] placeholder:font-normal placeholder:text-[#8e9cac]"
|
||||
:value="image.caption || ''"
|
||||
type="text"
|
||||
placeholder="비우면 표시하지 않음"
|
||||
@input="emit('update-media-image', imageIndex, { caption: $event.target.value })"
|
||||
>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<label class="inline-flex cursor-pointer items-center gap-2 text-xs font-semibold text-[#394047]">
|
||||
<input
|
||||
class="size-3.5 rounded border-[#c8ced3] text-[#15171a]"
|
||||
type="checkbox"
|
||||
:checked="image.useAlt"
|
||||
@change="emit('set-media-use-alt', imageIndex, $event.target.checked)"
|
||||
>
|
||||
파일명을 대체 텍스트로 사용
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="image.useAlt"
|
||||
class="text-[11px] font-normal text-[#8e9cac]"
|
||||
>
|
||||
{{ getImageDefaultAltLabel(image.url) || '(파일명 없음)' }}
|
||||
</p>
|
||||
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
|
||||
이미지 URL
|
||||
<input
|
||||
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a]"
|
||||
:value="image.url"
|
||||
type="text"
|
||||
@input="emit('update-media-image', imageIndex, { url: $event.target.value })"
|
||||
>
|
||||
</label>
|
||||
<div v-if="panel.kind === 'gallery'" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="imageIndex === 0"
|
||||
@click="emit('move-gallery-image', imageIndex, -1)"
|
||||
>
|
||||
위로
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="imageIndex === panel.images.length - 1"
|
||||
@click="emit('move-gallery-image', imageIndex, 1)"
|
||||
>
|
||||
아래로
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
|
||||
type="button"
|
||||
@click="emit('remove-media-image', imageIndex)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
|
||||
type="button"
|
||||
@click="emit('remove-media-image', imageIndex)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -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) => ``
|
||||
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)"
|
||||
>
|
||||
|
||||
@@ -54,6 +54,8 @@ const slugTouched = ref((() => {
|
||||
return Boolean(post.slug) && !isAdminPostDraftPlaceholderSlug(post.slug)
|
||||
})())
|
||||
const blockEditor = ref(null)
|
||||
/** 에디터 블록 설정 패널(게시물 설정 사이드바 오버레이) */
|
||||
const editorBlockPanel = ref({ open: false, panel: null })
|
||||
/** 본문 에디터 모드: write | preview */
|
||||
const editorMode = ref('write')
|
||||
const mediaItems = ref([])
|
||||
@@ -792,6 +794,87 @@ const openDateTimePicker = (event) => {
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* 에디터에서 블록 패널 상태를 수신한다.
|
||||
* @param {{ open: boolean, panel: Object|null }} payload - 패널 상태
|
||||
* @returns {void}
|
||||
*/
|
||||
const onEditorBlockPanel = (payload) => {
|
||||
editorBlockPanel.value = payload
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널 포커스 진입
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelFocusIn = () => {
|
||||
blockEditor.value?.handleBlockPanelFocusIn?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널 포커스 이탈
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelFocusOut = () => {
|
||||
blockEditor.value?.handleBlockPanelFocusOut?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널에서 미디어 이미지를 수정한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} patch - 변경 값
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelUpdateMediaImage = (imageIndex, patch) => {
|
||||
blockEditor.value?.updateActiveMediaImage?.(imageIndex, patch)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널에서 대체 텍스트 사용 여부를 바꾼다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {boolean} enabled - 사용 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelSetMediaUseAlt = (imageIndex, enabled) => {
|
||||
blockEditor.value?.setActiveMediaUseAlt?.(imageIndex, enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널에서 갤러리 이미지 순서를 바꾼다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {-1|1} direction - 이동 방향
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelMoveGalleryImage = (imageIndex, direction) => {
|
||||
blockEditor.value?.moveActiveGalleryImage?.(imageIndex, direction)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널에서 미디어 이미지를 삭제한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelRemoveMediaImage = (imageIndex) => {
|
||||
blockEditor.value?.removeActiveMediaImage?.(imageIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널에서 갤러리에 이미지를 추가한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelAddGalleryImages = () => {
|
||||
blockEditor.value?.openMediaPicker?.('active-gallery')
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널에서 임베드 URL을 수정한다.
|
||||
* @param {string} url - 임베드 URL
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelUpdateEmbedUrl = (url) => {
|
||||
blockEditor.value?.updateActiveEmbedUrl?.(url)
|
||||
}
|
||||
|
||||
const focusContentEditor = (event) => {
|
||||
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
|
||||
return
|
||||
@@ -1299,6 +1382,7 @@ defineExpose({
|
||||
v-model="form.content"
|
||||
v-model:editor-mode="editorMode"
|
||||
mode-toggle-teleport-to="#admin-post-form-mode-toggle-host"
|
||||
@block-panel="onEditorBlockPanel"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1310,7 +1394,7 @@ defineExpose({
|
||||
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
|
||||
:aria-hidden="!isSettingsOpen"
|
||||
>
|
||||
<div class="admin-post-form__settings-inner flex h-full w-[420px] flex-col">
|
||||
<div class="admin-post-form__settings-inner relative flex h-full w-[420px] flex-col">
|
||||
<div class="admin-post-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
|
||||
<h2 class="admin-post-form__settings-title text-xl font-bold text-black">
|
||||
게시물 설정
|
||||
@@ -1527,6 +1611,19 @@ defineExpose({
|
||||
<span>{{ deleting ? '삭제 중' : 'Delete post' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminEditorBlockPanel
|
||||
:open="editorBlockPanel.open"
|
||||
:panel="editorBlockPanel.panel"
|
||||
@panel-focus-in="onBlockPanelFocusIn"
|
||||
@panel-focus-out="onBlockPanelFocusOut"
|
||||
@update-media-image="onBlockPanelUpdateMediaImage"
|
||||
@set-media-use-alt="onBlockPanelSetMediaUseAlt"
|
||||
@move-gallery-image="onBlockPanelMoveGalleryImage"
|
||||
@remove-media-image="onBlockPanelRemoveMediaImage"
|
||||
@add-gallery-images="onBlockPanelAddGalleryImages"
|
||||
@update-embed-url="onBlockPanelUpdateEmbedUrl"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user