관리자 이미지와 갤러리 블록 추가
This commit is contained in:
@@ -15,8 +15,15 @@ const slashQuery = ref('')
|
||||
const slashMenuDirection = ref('down')
|
||||
const highlightedCommandIndex = ref(0)
|
||||
const isApplyingExternalValue = ref(false)
|
||||
const uploadingBlockIds = ref([])
|
||||
let blockIdSeed = 0
|
||||
|
||||
const imageWidthOptions = [
|
||||
{ value: 'regular', label: '기본' },
|
||||
{ value: 'wide', label: '와이드' },
|
||||
{ value: 'full', label: '풀사이즈' }
|
||||
]
|
||||
|
||||
const blockCommands = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
@@ -38,6 +45,18 @@ const blockCommands = [
|
||||
description: '작은 섹션 제목',
|
||||
keywords: ['h3', 'heading', 'subtitle', '제목']
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
label: '이미지',
|
||||
description: '단일 이미지 업로드',
|
||||
keywords: ['image', 'photo', '이미지', '사진']
|
||||
},
|
||||
{
|
||||
type: 'gallery',
|
||||
label: '갤러리',
|
||||
description: '여러 이미지 업로드',
|
||||
keywords: ['gallery', 'images', '갤러리', '사진']
|
||||
},
|
||||
{
|
||||
type: 'quote',
|
||||
label: '인용',
|
||||
@@ -70,15 +89,39 @@ const blockCommands = [
|
||||
* @param {string} text - 블록 텍스트
|
||||
* @param {number|null} level - 제목 레벨
|
||||
* @param {string} id - 블록 ID
|
||||
* @returns {{ id: string, type: string, text: string, level: number|null }} 에디터 블록
|
||||
* @param {Object} options - 추가 블록 옵션
|
||||
* @returns {Object} 에디터 블록
|
||||
*/
|
||||
const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '') => ({
|
||||
const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
||||
id: id || `editor-block-new-${blockIdSeed += 1}`,
|
||||
type,
|
||||
text,
|
||||
level
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 블록 옵션으로 변환
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {Object|null} 이미지 블록 옵션
|
||||
*/
|
||||
const parseImageLine = (line) => {
|
||||
const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
alt: match[1],
|
||||
url: match[2],
|
||||
width: match[3] || 'regular'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 마크다운 문자열을 에디터 블록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
@@ -98,6 +141,33 @@ const parseMarkdownToBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const images = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
const image = parseImageLine(lines[index])
|
||||
|
||||
if (image) {
|
||||
images.push(image)
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createEditorBlock('gallery', '', null, `editor-block-${blocks.length}`, { images }))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
blocks.push(createEditorBlock('image', '', null, `editor-block-${blocks.length}`, image))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const codeLines = []
|
||||
index += 1
|
||||
@@ -145,6 +215,19 @@ const parseMarkdownToBlocks = (markdown) => {
|
||||
return blocks.length ? blocks : [createEditorBlock('paragraph', '', null, 'editor-block-0')]
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 블록을 마크다운 문자열로 변환
|
||||
* @param {Object} image - 이미지 데이터
|
||||
* @returns {string} 이미지 마크다운
|
||||
*/
|
||||
const serializeImage = (image) => {
|
||||
const width = image.width && image.width !== 'regular'
|
||||
? `{width=${image.width}}`
|
||||
: ''
|
||||
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디터 블록 목록을 저장용 마크다운으로 변환
|
||||
* @returns {string} 마크다운 문자열
|
||||
@@ -158,6 +241,25 @@ const serializeBlocks = () => {
|
||||
return { type: block.type, value: '---' }
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
return block.url
|
||||
? { type: block.type, value: serializeImage(block) }
|
||||
: null
|
||||
}
|
||||
|
||||
if (block.type === 'gallery') {
|
||||
const images = block.images.filter((image) => image.url)
|
||||
|
||||
if (!images.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
type: block.type,
|
||||
value: [':::gallery', ...images.map((image) => serializeImage(image)), ':::'].join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
@@ -204,6 +306,13 @@ const emitContent = () => {
|
||||
emit('update:modelValue', serializeBlocks())
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 입력 블록 여부 반환
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @returns {boolean} 텍스트 입력 블록 여부
|
||||
*/
|
||||
const isTextBlock = (block) => !['divider', 'image', 'gallery'].includes(block.type)
|
||||
|
||||
/**
|
||||
* 블록 DOM 요소를 저장
|
||||
* @param {Element|null} element - 블록 DOM 요소
|
||||
@@ -214,7 +323,7 @@ const setBlockRef = (element, index) => {
|
||||
if (element) {
|
||||
blockRefs.value[index] = element
|
||||
|
||||
if (element.innerText !== editorBlocks.value[index]?.text) {
|
||||
if (isTextBlock(editorBlocks.value[index]) && element.innerText !== editorBlocks.value[index]?.text) {
|
||||
element.innerText = editorBlocks.value[index]?.text || ''
|
||||
}
|
||||
}
|
||||
@@ -305,6 +414,23 @@ const getBlockClass = (block) => [
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 이미지 너비 클래스 반환
|
||||
* @param {string} width - 이미지 너비 옵션
|
||||
* @returns {string} 클래스 문자열
|
||||
*/
|
||||
const getImageWidthClass = (width) => {
|
||||
if (width === 'full') {
|
||||
return 'admin-block-editor__image--full lg:-mx-20'
|
||||
}
|
||||
|
||||
if (width === 'wide') {
|
||||
return 'admin-block-editor__image--wide lg:-mx-10'
|
||||
}
|
||||
|
||||
return 'admin-block-editor__image--regular'
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 텍스트 입력 처리
|
||||
* @param {InputEvent} event - 입력 이벤트
|
||||
@@ -411,6 +537,10 @@ const applyCommand = (command) => {
|
||||
block.type = command.type
|
||||
block.level = command.level || null
|
||||
block.text = ''
|
||||
block.url = ''
|
||||
block.alt = ''
|
||||
block.width = 'regular'
|
||||
block.images = []
|
||||
const element = blockRefs.value[index]
|
||||
|
||||
if (element) {
|
||||
@@ -427,7 +557,132 @@ const applyCommand = (command) => {
|
||||
}
|
||||
|
||||
emitContent()
|
||||
focusBlock(index)
|
||||
|
||||
if (isTextBlock(block)) {
|
||||
focusBlock(index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 업로드 진행 상태 설정
|
||||
* @param {string} blockId - 블록 ID
|
||||
* @param {boolean} isUploading - 업로드 진행 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const setUploading = (blockId, isUploading) => {
|
||||
uploadingBlockIds.value = isUploading
|
||||
? [...new Set([...uploadingBlockIds.value, blockId])]
|
||||
: uploadingBlockIds.value.filter((id) => id !== blockId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 업로드 진행 상태 반환
|
||||
* @param {string} blockId - 블록 ID
|
||||
* @returns {boolean} 업로드 진행 여부
|
||||
*/
|
||||
const isUploading = (blockId) => uploadingBlockIds.value.includes(blockId)
|
||||
|
||||
/**
|
||||
* 이미지 파일 업로드
|
||||
* @param {FileList|Array<File>} files - 업로드 파일 목록
|
||||
* @returns {Promise<Array<Object>>} 업로드된 파일 목록
|
||||
*/
|
||||
const uploadImages = async (files) => {
|
||||
const formData = new FormData()
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
return result.files || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 이미지 파일 선택 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @param {Object} block - 이미지 블록
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleImageUpload = async (event, block) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(block.id, true)
|
||||
|
||||
try {
|
||||
const [file] = await uploadImages(files)
|
||||
|
||||
if (file) {
|
||||
block.url = file.url
|
||||
block.alt = block.alt || file.name.replace(/\.[^.]+$/g, '')
|
||||
emitContent()
|
||||
}
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
setUploading(block.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 파일 선택 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleGalleryUpload = async (event, block) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(block.id, true)
|
||||
|
||||
try {
|
||||
const uploadedFiles = await uploadImages(files)
|
||||
block.images = [
|
||||
...block.images,
|
||||
...uploadedFiles.map((file) => ({
|
||||
url: file.url,
|
||||
alt: file.name.replace(/\.[^.]+$/g, ''),
|
||||
width: 'regular'
|
||||
}))
|
||||
]
|
||||
emitContent()
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
setUploading(block.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 옵션 변경
|
||||
* @param {Object} block - 이미지 블록
|
||||
* @param {string} width - 너비 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateImageWidth = (block, width) => {
|
||||
block.width = width
|
||||
emitContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 삭제
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeGalleryImage = (block, imageIndex) => {
|
||||
block.images.splice(imageIndex, 1)
|
||||
emitContent()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,7 +736,7 @@ const handleEnter = (event, index) => {
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (currentBlock.type === 'divider') {
|
||||
if (['divider', 'image', 'gallery'].includes(currentBlock.type)) {
|
||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||
emitContent()
|
||||
focusBlock(index + 1)
|
||||
@@ -511,7 +766,11 @@ const handleEnter = (event, index) => {
|
||||
const handleBackspace = (event, index) => {
|
||||
const block = editorBlocks.value[index]
|
||||
|
||||
if (block.text || editorBlocks.value.length <= 1) {
|
||||
if ((isTextBlock(block) && block.text) || editorBlocks.value.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTextBlock(block) && (block.url || block.images.length)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -579,6 +838,80 @@ defineExpose({
|
||||
class="admin-block-editor__row relative"
|
||||
>
|
||||
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
|
||||
|
||||
<figure
|
||||
v-else-if="block.type === 'image'"
|
||||
class="admin-block-editor__media group my-6"
|
||||
:class="getImageWidthClass(block.width)"
|
||||
tabindex="0"
|
||||
@focus="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
<div v-if="block.url" class="admin-block-editor__image-frame relative overflow-hidden rounded bg-surface">
|
||||
<img class="admin-block-editor__image w-full object-cover" :src="block.url" :alt="block.alt">
|
||||
<div class="admin-block-editor__media-toolbar absolute inset-x-3 top-3 flex flex-wrap items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 group-focus:opacity-100">
|
||||
<button
|
||||
v-for="option in imageWidthOptions"
|
||||
:key="option.value"
|
||||
class="admin-block-editor__media-option rounded bg-white/95 px-3 py-1 text-xs font-semibold shadow"
|
||||
:class="block.width === option.value ? 'text-ink ring-1 ring-ink' : 'text-muted'"
|
||||
type="button"
|
||||
@click="updateImageWidth(block, option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
v-else
|
||||
class="admin-block-editor__upload grid cursor-pointer place-items-center rounded border border-dashed border-line bg-surface px-6 py-14 text-center text-sm font-semibold text-muted"
|
||||
>
|
||||
{{ isUploading(block.id) ? '업로드 중' : '이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="handleImageUpload($event, block)">
|
||||
</label>
|
||||
<input
|
||||
v-if="block.url"
|
||||
v-model="block.alt"
|
||||
class="admin-block-editor__caption mt-3 w-full border-0 bg-transparent text-center text-sm text-muted outline-none placeholder:text-soft"
|
||||
type="text"
|
||||
placeholder="이미지 설명"
|
||||
@input="emitContent"
|
||||
>
|
||||
</figure>
|
||||
|
||||
<figure
|
||||
v-else-if="block.type === 'gallery'"
|
||||
class="admin-block-editor__gallery group my-6"
|
||||
tabindex="0"
|
||||
@focus="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
<div v-if="block.images.length" class="admin-block-editor__gallery-grid grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
<div
|
||||
v-for="(image, imageIndex) in block.images"
|
||||
:key="`${block.id}-${image.url}`"
|
||||
class="admin-block-editor__gallery-item group/item relative overflow-hidden rounded bg-surface"
|
||||
>
|
||||
<img class="admin-block-editor__gallery-image aspect-[4/3] w-full object-cover" :src="image.url" :alt="image.alt">
|
||||
<button
|
||||
class="admin-block-editor__gallery-remove absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-ink opacity-0 shadow transition-opacity group-hover/item:opacity-100"
|
||||
type="button"
|
||||
@click="removeGalleryImage(block, imageIndex)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="admin-block-editor__gallery-upload mt-3 inline-flex cursor-pointer rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-ink">
|
||||
{{ isUploading(block.id) ? '업로드 중' : block.images.length ? '이미지 추가' : '갤러리 이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" multiple @change="handleGalleryUpload($event, block)">
|
||||
</label>
|
||||
</figure>
|
||||
|
||||
<component
|
||||
:is="getBlockTag(block)"
|
||||
v-else
|
||||
@@ -602,6 +935,7 @@ defineExpose({
|
||||
type="button"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
구분선
|
||||
</button>
|
||||
|
||||
@@ -6,21 +6,48 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const activeLightboxImages = ref([])
|
||||
const activeLightboxIndex = ref(0)
|
||||
|
||||
/**
|
||||
* 마크다운 블록을 생성
|
||||
* @param {string} type - 블록 타입
|
||||
* @param {string|Array<string>} text - 블록 텍스트
|
||||
* @param {number|null} level - 제목 레벨
|
||||
* @param {string} id - 블록 ID
|
||||
* @returns {{ id: string, type: string, text: string|Array<string>, level: number|null }} 블록
|
||||
* @param {Object} options - 추가 블록 옵션
|
||||
* @returns {Object} 블록
|
||||
*/
|
||||
const createBlock = (type = 'paragraph', text = '', level = null, id = '') => ({
|
||||
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
level
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 이미지 데이터로 변환
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {Object|null} 이미지 데이터
|
||||
*/
|
||||
const parseImageLine = (line) => {
|
||||
const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
alt: match[1],
|
||||
url: match[2],
|
||||
width: match[3] || 'regular'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
@@ -40,6 +67,33 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const images = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
const image = parseImageLine(lines[index])
|
||||
|
||||
if (image) {
|
||||
images.push(image)
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const codeLines = []
|
||||
index += 1
|
||||
@@ -94,6 +148,45 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
}
|
||||
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||
|
||||
/**
|
||||
* 라이트박스를 연다
|
||||
* @param {Array<Object>} images - 이미지 목록
|
||||
* @param {number} index - 시작 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const openLightbox = (images, index) => {
|
||||
activeLightboxImages.value = images
|
||||
activeLightboxIndex.value = index
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스를 닫는다
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeLightbox = () => {
|
||||
activeLightboxImages.value = []
|
||||
activeLightboxIndex.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스 이전 이미지로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const showPreviousImage = () => {
|
||||
activeLightboxIndex.value = activeLightboxIndex.value === 0
|
||||
? activeLightboxImages.value.length - 1
|
||||
: activeLightboxIndex.value - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스 다음 이미지로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const showNextImage = () => {
|
||||
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,6 +203,20 @@ const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
{{ item }}
|
||||
</li>
|
||||
</ProseList>
|
||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||
{{ block.alt }}
|
||||
</ProseImage>
|
||||
<div v-else-if="block.type === 'gallery'" class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
<button
|
||||
v-for="(image, imageIndex) in block.images"
|
||||
:key="`${block.id}-${image.url}`"
|
||||
class="content-markdown-renderer__gallery-button overflow-hidden rounded bg-surface"
|
||||
type="button"
|
||||
@click="openLightbox(block.images, imageIndex)"
|
||||
>
|
||||
<img class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]" :src="image.url" :alt="image.alt">
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
v-else-if="block.type === 'code'"
|
||||
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
|
||||
@@ -119,5 +226,34 @@ const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
{{ block.text }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="activeLightboxImage"
|
||||
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeLightbox"
|
||||
>
|
||||
<button class="content-markdown-renderer__lightbox-close absolute right-5 top-5 rounded bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeLightbox">
|
||||
닫기
|
||||
</button>
|
||||
<button
|
||||
v-if="activeLightboxImages.length > 1"
|
||||
class="content-markdown-renderer__lightbox-prev absolute left-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
||||
type="button"
|
||||
@click="showPreviousImage"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<img class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain" :src="activeLightboxImage.url" :alt="activeLightboxImage.alt">
|
||||
<button
|
||||
v-if="activeLightboxImages.length > 1"
|
||||
class="content-markdown-renderer__lightbox-next absolute right-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
||||
type="button"
|
||||
@click="showNextImage"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user