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>
|
<script setup>
|
||||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
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({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -18,7 +20,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue', 'block-panel'])
|
||||||
const activeMode = defineModel('editorMode', {
|
const activeMode = defineModel('editorMode', {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'write'
|
default: 'write'
|
||||||
@@ -49,83 +51,62 @@ const markdownValue = computed({
|
|||||||
set: (value) => emit('update:modelValue', value)
|
set: (value) => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** textarea 포커스·블록 패널 상호작용 */
|
||||||
|
const isTextareaFocused = ref(false)
|
||||||
|
const isBlockPanelEngaged = ref(false)
|
||||||
|
let blockPanelFocusTimer = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 마크다운 한 줄을 구조화한다.
|
* 현재 포커스가 블록 설정 패널 안에 있는지 확인한다.
|
||||||
* @param {string} line - 이미지 마크다운 줄
|
* @returns {boolean}
|
||||||
* @returns {{ alt: string, url: string, width: string }|null} 이미지 정보
|
|
||||||
*/
|
*/
|
||||||
const parseImageMarkdownLine = (line) => {
|
const isFocusInBlockPanel = () => Boolean(
|
||||||
const match = line.trim().match(/^!\[(.*?)\]\((.*?)\)(?:\{width=(regular|wide|full)\})?$/)
|
typeof document !== 'undefined'
|
||||||
|
&& document.activeElement?.closest?.('.admin-editor-block-panel')
|
||||||
|
)
|
||||||
|
|
||||||
if (!match) {
|
/**
|
||||||
return null
|
* 블록 패널 편집 중 상태를 유지한다.
|
||||||
}
|
* @returns {void}
|
||||||
|
*/
|
||||||
return {
|
const ensureBlockPanelEngaged = () => {
|
||||||
alt: match[1] || '',
|
window.clearTimeout(blockPanelFocusTimer)
|
||||||
url: match[2] || '',
|
isBlockPanelEngaged.value = true
|
||||||
width: match[3] || 'regular'
|
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 activeBlockContext = computed(() => resolveActiveBlockContext(
|
||||||
const lines = (markdownValue.value || '').split('\n')
|
markdownValue.value,
|
||||||
const currentLine = Math.min(activeLogicalLineIndex.value, lines.length - 1)
|
activeLogicalLineIndex.value
|
||||||
const activeImage = parseImageMarkdownLine(lines[currentLine] || '')
|
))
|
||||||
|
|
||||||
if (activeImage) {
|
/** @deprecated 내부 호환 alias */
|
||||||
return {
|
const activeMediaBlock = activeBlockContext
|
||||||
type: 'image',
|
|
||||||
startLine: currentLine,
|
|
||||||
endLine: currentLine,
|
|
||||||
images: [activeImage]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
* @returns {void}
|
||||||
break
|
*/
|
||||||
}
|
const syncBlockPanelState = () => {
|
||||||
|
emit('block-panel', {
|
||||||
|
open: isBlockPanelVisible.value,
|
||||||
|
panel: activeBlockContext.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if ((lines[index] || '').trim() === ':::') {
|
watch([isBlockPanelVisible, activeBlockContext], syncBlockPanelState, { deep: true })
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
|
* 본문의 논리 줄(`\\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}
|
* @returns {void}
|
||||||
@@ -174,11 +208,12 @@ const refreshCaretLogicalLine = () => {
|
|||||||
}
|
}
|
||||||
activeLogicalLineIndex.value = Math.max(0, lineIndex)
|
activeLogicalLineIndex.value = Math.max(0, lineIndex)
|
||||||
syncGutterScroll()
|
syncGutterScroll()
|
||||||
|
syncBlockPanelState()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* textarea 스크롤 시 선택 위치를 기억하고 거터를 동기화한다.
|
* textarea 스크롤 시 선택 위치를 기억하고 거터 스크롤을 맞춘다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const onTextareaScroll = () => {
|
const onTextareaScroll = () => {
|
||||||
@@ -234,6 +269,11 @@ const restoreTextareaFocus = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, () => {
|
watch(() => props.modelValue, () => {
|
||||||
|
if (isBlockPanelEngaged.value) {
|
||||||
|
syncBlockPanelState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
refreshCaretLogicalLine()
|
refreshCaretLogicalLine()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -320,11 +360,13 @@ onMounted(() => {
|
|||||||
document.addEventListener('keydown', onDocumentKeydown)
|
document.addEventListener('keydown', onDocumentKeydown)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(blockPanelFocusTimer)
|
||||||
document.removeEventListener('selectionchange', onSelectionChange)
|
document.removeEventListener('selectionchange', onSelectionChange)
|
||||||
document.removeEventListener('keydown', onDocumentKeydown)
|
document.removeEventListener('keydown', onDocumentKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
refreshCaretLogicalLine()
|
refreshCaretLogicalLine()
|
||||||
|
syncBlockPanelState()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -342,12 +384,6 @@ const focusFirstBlock = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
focusFirstBlock,
|
|
||||||
toggleEditorMode,
|
|
||||||
activeMode
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* textarea 선택 영역 정보를 반환한다.
|
* textarea 선택 영역 정보를 반환한다.
|
||||||
* @returns {{ start: number, end: number, value: string }} 선택 정보
|
* @returns {{ start: number, end: number, value: string }} 선택 정보
|
||||||
@@ -514,7 +550,7 @@ const insertCodeBlock = () => {
|
|||||||
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||||
* @returns {string} 이미지 마크다운
|
* @returns {string} 이미지 마크다운
|
||||||
*/
|
*/
|
||||||
const createImageMarkdown = (image) => ``
|
const createImageMarkdown = (image) => serializeImageMarkdown(image)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지정 줄 범위를 새 줄 목록으로 교체한다.
|
* 지정 줄 범위를 새 줄 목록으로 교체한다.
|
||||||
@@ -551,7 +587,7 @@ const replaceActiveMediaImages = (images) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === 'image') {
|
if (block.kind === 'image') {
|
||||||
if (!images[0]) {
|
if (!images[0]) {
|
||||||
replaceLineRange(block.startLine, block.endLine, [], false)
|
replaceLineRange(block.startLine, block.endLine, [], false)
|
||||||
return
|
return
|
||||||
@@ -586,10 +622,21 @@ const updateActiveMediaImage = (imageIndex, patch) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureBlockPanelEngaged()
|
||||||
const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image)
|
const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image)
|
||||||
replaceActiveMediaImages(images)
|
replaceActiveMediaImages(images)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 미디어 이미지의 파일명 대체 텍스트 사용 여부를 바꾼다.
|
||||||
|
* @param {number} imageIndex - 이미지 인덱스
|
||||||
|
* @param {boolean} enabled - 파일명 사용 여부
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const setActiveMediaUseAlt = (imageIndex, enabled) => {
|
||||||
|
updateActiveMediaImage(imageIndex, { useAlt: enabled })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 현재 갤러리 이미지 순서를 바꾼다.
|
* 현재 갤러리 이미지 순서를 바꾼다.
|
||||||
* @param {number} imageIndex - 이동할 이미지 인덱스
|
* @param {number} imageIndex - 이동할 이미지 인덱스
|
||||||
@@ -599,7 +646,7 @@ const updateActiveMediaImage = (imageIndex, patch) => {
|
|||||||
const moveActiveGalleryImage = (imageIndex, direction) => {
|
const moveActiveGalleryImage = (imageIndex, direction) => {
|
||||||
const block = activeMediaBlock.value
|
const block = activeMediaBlock.value
|
||||||
|
|
||||||
if (!block || block.type !== 'gallery') {
|
if (!block || block.kind !== 'gallery') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,7 +685,7 @@ const removeActiveMediaImage = (imageIndex) => {
|
|||||||
const appendImagesToActiveGallery = (images) => {
|
const appendImagesToActiveGallery = (images) => {
|
||||||
const block = activeMediaBlock.value
|
const block = activeMediaBlock.value
|
||||||
|
|
||||||
if (!block || block.type !== 'gallery') {
|
if (!block || block.kind !== 'gallery') {
|
||||||
insertGallery(images)
|
insertGallery(images)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -646,6 +693,33 @@ const appendImagesToActiveGallery = (images) => {
|
|||||||
replaceActiveMediaImages([...block.images, ...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 - 이미지 정보
|
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||||
@@ -696,6 +770,21 @@ const openMediaPicker = async (target) => {
|
|||||||
await fetchMediaItems()
|
await fetchMediaItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focusFirstBlock,
|
||||||
|
toggleEditorMode,
|
||||||
|
activeMode,
|
||||||
|
handleBlockPanelFocusIn,
|
||||||
|
handleBlockPanelFocusOut,
|
||||||
|
updateActiveMediaImage,
|
||||||
|
setActiveMediaUseAlt,
|
||||||
|
moveActiveGalleryImage,
|
||||||
|
removeActiveMediaImage,
|
||||||
|
appendImagesToActiveGallery,
|
||||||
|
updateActiveEmbedUrl,
|
||||||
|
openMediaPicker
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 선택 창을 닫는다.
|
* 미디어 선택 창을 닫는다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -766,18 +855,21 @@ const applyMediaSelection = () => {
|
|||||||
if (item) {
|
if (item) {
|
||||||
insertImage({
|
insertImage({
|
||||||
url: item.url,
|
url: item.url,
|
||||||
alt: item.name || ''
|
useAlt: false,
|
||||||
|
caption: ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (mediaPickerTarget.value === 'active-gallery') {
|
} else if (mediaPickerTarget.value === 'active-gallery') {
|
||||||
appendImagesToActiveGallery(selectedItems.map((item) => ({
|
appendImagesToActiveGallery(selectedItems.map((item) => ({
|
||||||
url: item.url,
|
url: item.url,
|
||||||
alt: item.name || ''
|
useAlt: false,
|
||||||
|
caption: ''
|
||||||
})))
|
})))
|
||||||
} else {
|
} else {
|
||||||
insertGallery(selectedItems.map((item) => ({
|
insertGallery(selectedItems.map((item) => ({
|
||||||
url: item.url,
|
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 uploadedFiles = await uploadImages(files)
|
||||||
const images = uploadedFiles.map((file) => ({
|
const images = uploadedFiles.map((file) => ({
|
||||||
url: file.url,
|
url: file.url,
|
||||||
alt: file.name || ''
|
useAlt: false,
|
||||||
|
caption: ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (target === 'gallery') {
|
if (target === 'gallery') {
|
||||||
@@ -1213,94 +1306,10 @@ const handleKeydown = (event) => {
|
|||||||
@click="refreshCaretLogicalLine"
|
@click="refreshCaretLogicalLine"
|
||||||
@keyup="refreshCaretLogicalLine"
|
@keyup="refreshCaretLogicalLine"
|
||||||
@select="refreshCaretLogicalLine"
|
@select="refreshCaretLogicalLine"
|
||||||
@focus="refreshCaretLogicalLine"
|
@focus="onTextareaFocus"
|
||||||
|
@blur="onTextareaBlur"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 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>
|
</div>
|
||||||
@@ -1376,7 +1385,7 @@ const handleKeydown = (event) => {
|
|||||||
v-for="item in filteredMediaItems"
|
v-for="item in filteredMediaItems"
|
||||||
:key="item.url"
|
:key="item.url"
|
||||||
class="admin-markdown-editor__media-item group overflow-hidden rounded border bg-white text-left transition"
|
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"
|
type="button"
|
||||||
@click="toggleMediaSelection(item)"
|
@click="toggleMediaSelection(item)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const slugTouched = ref((() => {
|
|||||||
return Boolean(post.slug) && !isAdminPostDraftPlaceholderSlug(post.slug)
|
return Boolean(post.slug) && !isAdminPostDraftPlaceholderSlug(post.slug)
|
||||||
})())
|
})())
|
||||||
const blockEditor = ref(null)
|
const blockEditor = ref(null)
|
||||||
|
/** 에디터 블록 설정 패널(게시물 설정 사이드바 오버레이) */
|
||||||
|
const editorBlockPanel = ref({ open: false, panel: null })
|
||||||
/** 본문 에디터 모드: write | preview */
|
/** 본문 에디터 모드: write | preview */
|
||||||
const editorMode = ref('write')
|
const editorMode = ref('write')
|
||||||
const mediaItems = ref([])
|
const mediaItems = ref([])
|
||||||
@@ -792,6 +794,87 @@ const openDateTimePicker = (event) => {
|
|||||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
* @returns {void}
|
* @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) => {
|
const focusContentEditor = (event) => {
|
||||||
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
|
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
|
||||||
return
|
return
|
||||||
@@ -1299,6 +1382,7 @@ defineExpose({
|
|||||||
v-model="form.content"
|
v-model="form.content"
|
||||||
v-model:editor-mode="editorMode"
|
v-model:editor-mode="editorMode"
|
||||||
mode-toggle-teleport-to="#admin-post-form-mode-toggle-host"
|
mode-toggle-teleport-to="#admin-post-form-mode-toggle-host"
|
||||||
|
@block-panel="onEditorBlockPanel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1310,7 +1394,7 @@ defineExpose({
|
|||||||
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
|
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
|
||||||
:aria-hidden="!isSettingsOpen"
|
: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">
|
<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">
|
<h2 class="admin-post-form__settings-title text-xl font-bold text-black">
|
||||||
게시물 설정
|
게시물 설정
|
||||||
@@ -1527,6 +1611,19 @@ defineExpose({
|
|||||||
<span>{{ deleting ? '삭제 중' : 'Delete post' }}</span>
|
<span>{{ deleting ? '삭제 중' : 'Delete post' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import {
|
||||||
|
getImageAltAttribute,
|
||||||
|
getImageCaption,
|
||||||
|
parseImageMarkdownLine
|
||||||
|
} from '../../lib/markdown-image.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
content: {
|
content: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -27,6 +33,8 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
|
|||||||
level,
|
level,
|
||||||
url: options.url || '',
|
url: options.url || '',
|
||||||
alt: options.alt || '',
|
alt: options.alt || '',
|
||||||
|
caption: options.caption || '',
|
||||||
|
useAlt: options.useAlt === true,
|
||||||
title: options.title || '',
|
title: options.title || '',
|
||||||
variant: options.variant || '',
|
variant: options.variant || '',
|
||||||
ordered: options.ordered || false,
|
ordered: options.ordered || false,
|
||||||
@@ -85,19 +93,7 @@ const parseCalloutOptions = (line) => {
|
|||||||
* @param {string} line - 마크다운 행
|
* @param {string} line - 마크다운 행
|
||||||
* @returns {Object|null} 이미지 데이터
|
* @returns {Object|null} 이미지 데이터
|
||||||
*/
|
*/
|
||||||
const parseImageLine = (line) => {
|
const parseImageLine = (line) => parseImageMarkdownLine(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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
||||||
@@ -555,6 +551,56 @@ const showPreviousImage = () => {
|
|||||||
const showNextImage = () => {
|
const showNextImage = () => {
|
||||||
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
|
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트박스 키보드 조작(Esc 닫기, 좌우 이전·다음)
|
||||||
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleLightboxKeydown = (event) => {
|
||||||
|
if (!activeLightboxImage.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
closeLightbox()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeLightboxImages.value.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
showPreviousImage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault()
|
||||||
|
showNextImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeLightboxImage, (image) => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
window.addEventListener('keydown', handleLightboxKeydown)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', handleLightboxKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
window.removeEventListener('keydown', handleLightboxKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -590,8 +636,15 @@ const showNextImage = () => {
|
|||||||
</template>
|
</template>
|
||||||
</li>
|
</li>
|
||||||
</ProseList>
|
</ProseList>
|
||||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
<ProseImage
|
||||||
{{ block.alt }}
|
v-else-if="block.type === 'image'"
|
||||||
|
:src="block.url"
|
||||||
|
:alt="getImageAltAttribute(block)"
|
||||||
|
:variant="block.width"
|
||||||
|
>
|
||||||
|
<template v-if="getImageCaption(block)">
|
||||||
|
{{ getImageCaption(block) }}
|
||||||
|
</template>
|
||||||
</ProseImage>
|
</ProseImage>
|
||||||
<ProseCallout
|
<ProseCallout
|
||||||
v-else-if="block.type === 'callout'"
|
v-else-if="block.type === 'callout'"
|
||||||
@@ -639,7 +692,7 @@ const showNextImage = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
@click="openLightbox(block.images, imageIndex)"
|
@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">
|
<img class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]" :src="image.url" :alt="getImageAltAttribute(image)">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre
|
<pre
|
||||||
@@ -666,6 +719,8 @@ const showNextImage = () => {
|
|||||||
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
|
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-label="갤러리 이미지 보기"
|
||||||
|
tabindex="-1"
|
||||||
@click.self="closeLightbox"
|
@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 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">
|
||||||
@@ -679,7 +734,11 @@ const showNextImage = () => {
|
|||||||
>
|
>
|
||||||
이전
|
이전
|
||||||
</button>
|
</button>
|
||||||
<img class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain" :src="activeLightboxImage.url" :alt="activeLightboxImage.alt">
|
<img
|
||||||
|
class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain"
|
||||||
|
:src="activeLightboxImage.url"
|
||||||
|
:alt="getImageAltAttribute(activeLightboxImage)"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-if="activeLightboxImages.length > 1"
|
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"
|
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"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ defineProps({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<figure
|
<figure
|
||||||
class="prose-image my-8"
|
class="prose-image mb-2.5"
|
||||||
:class="{
|
:class="{
|
||||||
'prose-image--wide lg:-mx-10 lg:max-w-none': variant === 'wide',
|
'prose-image--wide lg:-mx-10 lg:max-w-none': variant === 'wide',
|
||||||
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
|
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
|
||||||
@@ -26,7 +26,7 @@ defineProps({
|
|||||||
<div class="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
<div class="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||||
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
|
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
|
||||||
</div>
|
</div>
|
||||||
<figcaption v-if="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-[var(--site-muted)]">
|
<figcaption v-if="$slots.default" class="prose-image__caption mt-1.5 text-center text-sm text-[var(--site-muted)]">
|
||||||
<slot />
|
<slot />
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|||||||
@@ -67,7 +67,8 @@
|
|||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
|
||||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 툴바 `이미지`·`갤러리` + 미디어 모달(라이브러리 기본·업로드 탭), 현재 이미지·갤러리 편집 패널(너비 UI 없음) |
|
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 툴바 `이미지`·`갤러리` + 미디어 모달, 커서 블록 컨텍스트·`block-panel` emit |
|
||||||
|
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드) |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||||
| 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 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||||
|
|||||||
17
docs/spec.md
17
docs/spec.md
@@ -176,12 +176,13 @@ components/content/
|
|||||||
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
|
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
|
||||||
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`)
|
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`)
|
||||||
- 이미지
|
- 이미지
|
||||||
- 기본: ``
|
- 기본: `` — 대체 텍스트 없음. **파일명 사용** 토글 시 ``로 저장·렌더
|
||||||
- 와이드/풀: `{width=wide|full}`
|
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시, 파일명은 기본 노출하지 않음
|
||||||
|
- 와이드/풀: `{width=wide|full}` 또는 캡션·width 조합
|
||||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
||||||
- 이미지 갤러리
|
- 이미지 갤러리
|
||||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||||
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
|
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스, Esc 닫기·←/→ 이전·다음)
|
||||||
- 문단과 줄바꿈
|
- 문단과 줄바꿈
|
||||||
- 관리자 Markdown-first 에디터에서 일반 Enter는 브라우저 기본 단일 줄 이동으로 새 문단을 만든다.
|
- 관리자 Markdown-first 에디터에서 일반 Enter는 브라우저 기본 단일 줄 이동으로 새 문단을 만든다.
|
||||||
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
|
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
|
||||||
@@ -481,8 +482,9 @@ components/content/
|
|||||||
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
|
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
|
||||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `` 형식으로 삽입한다.
|
- 미디어 라이브러리에서 단일 이미지를 선택하면 `` 형식으로 삽입한다.
|
||||||
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
||||||
- 작성 모드에서 커서가 이미지 마크다운 줄 또는 `:::gallery` 블록 안에 있으면 현재 미디어 블록 편집 패널을 표시한다.
|
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문 포커스가 완전히 이탈하면 슬라이드 아웃한다.
|
||||||
- 현재 미디어 블록 편집 패널은 alt, URL을 수정하고 갤러리 이미지 순서 변경, 삭제, 미디어 라이브러리 이미지 추가를 지원한다.
|
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 대체 텍스트로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL). `AdminMarkdownEditor`는 `block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
|
||||||
|
- 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 **주황(`#ff7a00`) 굵은 테두리**로 표시한다.
|
||||||
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
||||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태 문구, Preview, 상태별 주요 액션(Publish / Update·Unpublish / Update·Unschedule), 설정 패널 토글을 제공한다.
|
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태 문구, Preview, 상태별 주요 액션(Publish / Update·Unpublish / Update·Unschedule), 설정 패널 토글을 제공한다.
|
||||||
@@ -526,9 +528,8 @@ components/content/
|
|||||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 토글 시 `` 형식으로 저장한다.
|
||||||
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
- 이미지/갤러리 삽입 시 대체 텍스트는 기본 비우며, 블록 설정 패널에서 **파일명을 대체 텍스트로 사용** 토글로만 켠다.
|
||||||
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
|
||||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||||
- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
|
- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.2.1
|
||||||
|
|
||||||
|
- 관리자 블록 설정 패널: 게시물 설정 사이드바(420px) 오버레이 슬라이드, 이미지·갤러리·임베드 편집.
|
||||||
|
- 이미지: 캡션·대체 텍스트 분리, **파일명을 대체 텍스트로 사용** 토글(기본 끔), `lib/markdown-image.js`·`lib/markdown-block-context.js` 추가.
|
||||||
|
- 블록 패널 캡션·URL 입력 중 포커스 이탈로 패널이 닫히던 문제 수정.
|
||||||
|
- 공개 렌더: 갤러리 라이트박스 Esc·좌우 방향키, 캡션만 figcaption 표시.
|
||||||
|
- 패키지 버전 `1.2.1`로 갱신.
|
||||||
|
|
||||||
## v1.2.0
|
## v1.2.0
|
||||||
|
|
||||||
- 관리자 글 목록: 발행일 기준 정렬(`published_at` 우선, 없으면 `updated_at`), 총·추천·필터 표시 개수, 추천만 필터, 추천 글 별(★) 열.
|
- 관리자 글 목록: 발행일 기준 정렬(`published_at` 우선, 없으면 `updated_at`), 총·추천·필터 표시 개수, 추천만 필터, 추천 글 별(★) 열.
|
||||||
- 관리자 글 슬러그: Post URL 미리보기 즉시 반영·저장 전 안내, 초안은 제목 연동 자동 슬러그(연한 표시), 발행·예약 글은 제목 변경 시 슬러그 고정(중복 409 예방).
|
- 관리자 글 슬러그: Post URL 미리보기 즉시 반영·저장 전 안내, 초안은 제목 연동 자동 슬러그(연한 표시), 발행·예약 글은 제목 변경 시 슬러그 고정(중복 409 예방).
|
||||||
- 예약·발행 시각: 달력·KST 클릭 영역 `showPicker` 연동.
|
- 예약·발행 시각: 달력·KST 클릭 영역 `showPicker` 연동.
|
||||||
|
- 이미지·갤러리: 캡션은 사용자 입력 시만 표시(``), 대체 텍스트는 기본 비움·**파일명 사용** 토글 시 URL 파일명을 alt로 저장, 블록 설정 패널을 게시물 설정 사이드바 오버레이로 슬라이드, 갤러리 다중 선택 주황 테두리, 라이트박스 Esc·좌우 방향키.
|
||||||
|
- `lib/markdown-block-context.js`, `AdminEditorBlockPanel.vue` 추가.
|
||||||
- 패키지 버전 `1.2.0`으로 갱신.
|
- 패키지 버전 `1.2.0`으로 갱신.
|
||||||
|
|
||||||
## v1.1.19
|
## v1.1.19
|
||||||
|
|||||||
124
lib/markdown-block-context.js
Normal file
124
lib/markdown-block-context.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { parseImageMarkdownLine } from './markdown-image.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fenced 블록 시작 줄 인덱스를 찾는다.
|
||||||
|
* @param {string[]} lines - 본문 줄 목록
|
||||||
|
* @param {number} currentLine - 현재 줄
|
||||||
|
* @param {string} opener - 시작 토큰
|
||||||
|
* @returns {number} 시작 줄 또는 -1
|
||||||
|
*/
|
||||||
|
const findFencedBlockStart = (lines, currentLine, opener) => {
|
||||||
|
for (let index = currentLine; index >= 0; index -= 1) {
|
||||||
|
if ((lines[index] || '').trim() === opener) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((lines[index] || '').trim() === ':::') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fenced 블록 종료 줄 인덱스를 찾는다.
|
||||||
|
* @param {string[]} lines - 본문 줄 목록
|
||||||
|
* @param {number} startLine - 시작 줄
|
||||||
|
* @returns {number} 종료 줄 또는 -1
|
||||||
|
*/
|
||||||
|
const findFencedBlockEnd = (lines, startLine) => {
|
||||||
|
for (let index = startLine + 1; index < lines.length; index += 1) {
|
||||||
|
if ((lines[index] || '').trim() === ':::') {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 fenced 블록을 파싱한다.
|
||||||
|
* @param {string[]} lines - 본문 줄 목록
|
||||||
|
* @param {number} currentLine - 현재 줄
|
||||||
|
* @returns {{ kind: 'gallery', startLine: number, endLine: number, images: Array<Object> }|null}
|
||||||
|
*/
|
||||||
|
const resolveGalleryBlock = (lines, currentLine) => {
|
||||||
|
const galleryStart = findFencedBlockStart(lines, currentLine, ':::gallery')
|
||||||
|
|
||||||
|
if (galleryStart === -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryEnd = findFencedBlockEnd(lines, galleryStart)
|
||||||
|
|
||||||
|
if (galleryEnd === -1 || currentLine > galleryEnd) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'gallery',
|
||||||
|
startLine: galleryStart,
|
||||||
|
endLine: galleryEnd,
|
||||||
|
images: lines
|
||||||
|
.slice(galleryStart + 1, galleryEnd)
|
||||||
|
.map(parseImageMarkdownLine)
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임베드 fenced 블록을 파싱한다.
|
||||||
|
* @param {string[]} lines - 본문 줄 목록
|
||||||
|
* @param {number} currentLine - 현재 줄
|
||||||
|
* @returns {{ kind: 'embed', startLine: number, endLine: number, url: string }|null}
|
||||||
|
*/
|
||||||
|
const resolveEmbedBlock = (lines, currentLine) => {
|
||||||
|
const embedStart = findFencedBlockStart(lines, currentLine, ':::embed')
|
||||||
|
|
||||||
|
if (embedStart === -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedEnd = findFencedBlockEnd(lines, embedStart)
|
||||||
|
|
||||||
|
if (embedEnd === -1 || currentLine > embedEnd) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'embed',
|
||||||
|
startLine: embedStart,
|
||||||
|
endLine: embedEnd,
|
||||||
|
url: lines.slice(embedStart + 1, embedEnd).join('\n').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커서 줄 기준 활성 블록 컨텍스트를 반환한다.
|
||||||
|
* @param {string} markdown - 본문 마크다운
|
||||||
|
* @param {number} lineIndex - 현재 줄(0-based)
|
||||||
|
* @returns {Object|null} 블록 컨텍스트
|
||||||
|
*/
|
||||||
|
export const resolveActiveBlockContext = (markdown, lineIndex) => {
|
||||||
|
const lines = String(markdown || '').split('\n')
|
||||||
|
const currentLine = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1))
|
||||||
|
const activeImage = parseImageMarkdownLine(lines[currentLine] || '')
|
||||||
|
|
||||||
|
if (activeImage) {
|
||||||
|
return {
|
||||||
|
kind: 'image',
|
||||||
|
startLine: currentLine,
|
||||||
|
endLine: currentLine,
|
||||||
|
images: [activeImage]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gallery = resolveGalleryBlock(lines, currentLine)
|
||||||
|
|
||||||
|
if (gallery) {
|
||||||
|
return gallery
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveEmbedBlock(lines, currentLine)
|
||||||
|
}
|
||||||
100
lib/markdown-image.js
Normal file
100
lib/markdown-image.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/** @type {RegExp} 이미지 마크다운 한 줄 */
|
||||||
|
const IMAGE_MARKDOWN_LINE_RE = /^!\[(.*?)\]\((\S+?)(?:\s+"((?:[^"\\]|\\.)*)")?\)(?:\{width=(regular|wide|full)\})?$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캡션 문자열 이스케이프 해제
|
||||||
|
* @param {string} value - 이스케이프된 캡션
|
||||||
|
* @returns {string} 캡션
|
||||||
|
*/
|
||||||
|
const unescapeImageCaption = (value) => String(value || '').replace(/\\"/g, '"')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캡션 문자열 이스케이프
|
||||||
|
* @param {string} value - 캡션
|
||||||
|
* @returns {string} 이스케이프된 캡션
|
||||||
|
*/
|
||||||
|
const escapeImageCaption = (value) => String(value || '').replace(/"/g, '\\"')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL에서 기본 대체 텍스트(파일명) 추출
|
||||||
|
* @param {string} url - 이미지 URL
|
||||||
|
* @returns {string} 파일명 기반 라벨
|
||||||
|
*/
|
||||||
|
export const getImageDefaultAltLabel = (url) => {
|
||||||
|
const raw = String(url || '').trim()
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pathname = new URL(raw, 'https://sori.studio').pathname
|
||||||
|
return decodeURIComponent(pathname.split('/').filter(Boolean).pop() || '')
|
||||||
|
} catch {
|
||||||
|
const withoutQuery = raw.split('?')[0].split('#')[0]
|
||||||
|
return decodeURIComponent(withoutQuery.split('/').filter(Boolean).pop() || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 마크다운 한 줄 파싱
|
||||||
|
* @param {string} line - 마크다운 줄
|
||||||
|
* @returns {{ url: string, width: string, caption: string, useAlt: boolean }|null}
|
||||||
|
*/
|
||||||
|
export const parseImageMarkdownLine = (line) => {
|
||||||
|
const match = String(line || '').trim().match(IMAGE_MARKDOWN_LINE_RE)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const alt = match[1] || ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: match[2] || '',
|
||||||
|
caption: unescapeImageCaption(match[3]),
|
||||||
|
width: match[4] || 'regular',
|
||||||
|
/** true이면 대체 텍스트로 URL 파일명을 사용한다 */
|
||||||
|
useAlt: Boolean(alt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 마크다운 한 줄 생성
|
||||||
|
* @param {{ url: string, caption?: string, width?: string, useAlt?: boolean }} image - 이미지 정보
|
||||||
|
* @returns {string} 마크다운 줄
|
||||||
|
*/
|
||||||
|
export const serializeImageMarkdown = (image) => {
|
||||||
|
const url = String(image.url || '').trim()
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const alt = image.useAlt === true ? getImageDefaultAltLabel(url) : ''
|
||||||
|
const caption = String(image.caption ?? '').trim()
|
||||||
|
const titlePart = caption ? ` "${escapeImageCaption(caption)}"` : ''
|
||||||
|
const width = image.width && image.width !== 'regular' ? `{width=${image.width}}` : ''
|
||||||
|
|
||||||
|
return `${width}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 렌더용 img alt 텍스트
|
||||||
|
* @param {{ url?: string, useAlt?: boolean }} image - 이미지 정보
|
||||||
|
* @returns {string} alt 속성값
|
||||||
|
*/
|
||||||
|
export const getImageAltAttribute = (image) => {
|
||||||
|
if (!image?.useAlt) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return getImageDefaultAltLabel(image.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 렌더용 캡션(표시용 figcaption)
|
||||||
|
* @param {{ caption?: string }} image - 이미지 정보
|
||||||
|
* @returns {string} 캡션
|
||||||
|
*/
|
||||||
|
export const getImageCaption = (image) => String(image?.caption || '').trim()
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
Reference in New Issue
Block a user