474 lines
18 KiB
Vue
474 lines
18 KiB
Vue
<script setup>
|
|
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
|
import {
|
|
CALLOUT_BACKGROUND_OPTIONS,
|
|
CALLOUT_EMOJI_OPTIONS,
|
|
QUOTE_BACKGROUND_LABELS,
|
|
QUOTE_BACKGROUND_OPTIONS,
|
|
QUOTE_BACKGROUND_SWATCHES
|
|
} from '../../lib/markdown-callout.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',
|
|
'update-quote-background',
|
|
'update-callout-options',
|
|
'update-code-options',
|
|
'update-toggle-options'
|
|
])
|
|
|
|
const backgroundLabels = {
|
|
gray: '회색',
|
|
blue: '파랑',
|
|
green: '초록',
|
|
yellow: '노랑',
|
|
red: '빨강',
|
|
purple: '보라',
|
|
pink: '분홍'
|
|
}
|
|
|
|
const backgroundSwatches = {
|
|
gray: 'rgba(100,116,139,0.28)',
|
|
blue: 'rgba(59,130,246,0.3)',
|
|
green: 'rgba(34,197,94,0.3)',
|
|
yellow: 'rgba(245,158,11,0.34)',
|
|
red: 'rgba(239,68,68,0.3)',
|
|
purple: 'rgba(168,85,247,0.3)',
|
|
pink: 'rgba(236,72,153,0.28)'
|
|
}
|
|
|
|
const languageOptions = ['', 'javascript', 'html', 'css', 'vue', 'json', 'bash', 'markdown', 'sql']
|
|
|
|
/**
|
|
* 배경 라벨을 반환한다.
|
|
* @param {string} background - 배경 키
|
|
* @returns {string} 배경 라벨
|
|
*/
|
|
const getBackgroundLabel = (background) => backgroundLabels[background] || background
|
|
|
|
/**
|
|
* 배경 스와치를 반환한다.
|
|
* @param {string} background - 배경 키
|
|
* @returns {string} CSS 배경
|
|
*/
|
|
const getBackgroundSwatch = (background) => backgroundSwatches[background] || 'rgba(100,116,139,0.28)'
|
|
|
|
/**
|
|
* 인용 배경 라벨을 반환한다.
|
|
* @param {string} background - 배경 키
|
|
* @returns {string} 배경 라벨
|
|
*/
|
|
const getQuoteBackgroundLabel = (background) => QUOTE_BACKGROUND_LABELS[background] || background
|
|
|
|
/**
|
|
* 인용 배경 스와치를 반환한다.
|
|
* @param {string} background - 배경 키
|
|
* @returns {string} CSS 배경
|
|
*/
|
|
const getQuoteBackgroundSwatch = (background) => QUOTE_BACKGROUND_SWATCHES[background] || QUOTE_BACKGROUND_SWATCHES.gray
|
|
|
|
/**
|
|
* 블록 종류 라벨
|
|
* @returns {string}
|
|
*/
|
|
const panelTitle = computed(() => {
|
|
if (!props.panel) {
|
|
return ''
|
|
}
|
|
|
|
if (props.panel.kind === 'gallery') {
|
|
return '갤러리'
|
|
}
|
|
|
|
if (props.panel.kind === 'embed') {
|
|
return '임베드'
|
|
}
|
|
|
|
if (props.panel.kind === 'quote') {
|
|
return '인용'
|
|
}
|
|
|
|
if (props.panel.kind === 'callout') {
|
|
return '콜아웃'
|
|
}
|
|
|
|
if (props.panel.kind === 'code') {
|
|
return '코드 블록'
|
|
}
|
|
|
|
if (props.panel.kind === 'toggle') {
|
|
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'
|
|
}
|
|
|
|
if (props.panel.kind === 'quote') {
|
|
return '인용 배경색'
|
|
}
|
|
|
|
if (props.panel.kind === 'callout') {
|
|
return '아이콘·배경색'
|
|
}
|
|
|
|
if (props.panel.kind === 'code') {
|
|
return '언어·줄번호'
|
|
}
|
|
|
|
if (props.panel.kind === 'toggle') {
|
|
return '기본 펼침 상태'
|
|
}
|
|
|
|
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-if="panel.kind === 'quote'">
|
|
<div class="admin-editor-block-panel__quote-settings grid gap-4">
|
|
<div>
|
|
<p class="text-sm font-semibold text-[#394047]">
|
|
배경색
|
|
</p>
|
|
<p class="mt-1 text-xs leading-relaxed text-[#8e9cac]">
|
|
현재 인용 블록의 첫 줄에 배경 옵션을 추가합니다.
|
|
</p>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<button
|
|
v-for="background in QUOTE_BACKGROUND_OPTIONS"
|
|
:key="`quote-background-${background}`"
|
|
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
|
|
:class="panel.quoteBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
|
type="button"
|
|
@click="emit('update-quote-background', background)"
|
|
>
|
|
<span
|
|
class="size-5 shrink-0 rounded-full border border-black/5"
|
|
:style="{ background: getQuoteBackgroundSwatch(background) }"
|
|
aria-hidden="true"
|
|
/>
|
|
<span>{{ getQuoteBackgroundLabel(background) }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="panel.kind === 'callout'">
|
|
<div class="admin-editor-block-panel__callout-settings grid gap-5">
|
|
<label class="flex cursor-pointer items-center justify-between gap-3 rounded border border-[#edf0f2] bg-[#fafafa] px-3 py-3 text-sm font-semibold text-[#394047]">
|
|
<span>아이콘 표시</span>
|
|
<input
|
|
class="size-4 rounded border-[#c8ced3] text-[#15171a]"
|
|
type="checkbox"
|
|
:checked="panel.calloutEmojiEnabled"
|
|
@change="emit('update-callout-options', { calloutEmojiEnabled: $event.target.checked })"
|
|
>
|
|
</label>
|
|
|
|
<div class="grid gap-2">
|
|
<p class="text-sm font-semibold text-[#394047]">
|
|
아이콘
|
|
</p>
|
|
<input
|
|
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac] disabled:opacity-50"
|
|
:value="panel.calloutEmoji"
|
|
type="text"
|
|
maxlength="4"
|
|
placeholder="💡"
|
|
:disabled="!panel.calloutEmojiEnabled"
|
|
@input="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: $event.target.value })"
|
|
>
|
|
<div class="grid grid-cols-5 gap-2">
|
|
<button
|
|
v-for="emoji in CALLOUT_EMOJI_OPTIONS"
|
|
:key="`callout-emoji-${emoji}`"
|
|
class="grid h-10 place-items-center rounded border text-lg transition"
|
|
:class="panel.calloutEmoji === emoji && panel.calloutEmojiEnabled ? 'border-[#15171a] bg-white' : 'border-[#dce0e5] bg-[#fafafa] hover:bg-white'"
|
|
type="button"
|
|
:disabled="!panel.calloutEmojiEnabled"
|
|
@click="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: emoji })"
|
|
>
|
|
{{ emoji }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
<p class="text-sm font-semibold text-[#394047]">
|
|
배경색
|
|
</p>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<button
|
|
v-for="background in CALLOUT_BACKGROUND_OPTIONS"
|
|
:key="`callout-background-${background}`"
|
|
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
|
|
:class="panel.calloutBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
|
type="button"
|
|
@click="emit('update-callout-options', { calloutBackground: background })"
|
|
>
|
|
<span
|
|
class="size-5 shrink-0 rounded-full border border-black/5"
|
|
:style="{ background: getBackgroundSwatch(background) }"
|
|
aria-hidden="true"
|
|
/>
|
|
<span>{{ getBackgroundLabel(background) }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="panel.kind === 'code'">
|
|
<div class="admin-editor-block-panel__code-settings grid gap-4">
|
|
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
|
<span class="font-semibold text-[#394047]">언어</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.language"
|
|
type="text"
|
|
list="admin-editor-block-panel-languages"
|
|
placeholder="javascript"
|
|
@input="emit('update-code-options', { language: $event.target.value })"
|
|
>
|
|
<datalist id="admin-editor-block-panel-languages">
|
|
<option
|
|
v-for="language in languageOptions"
|
|
:key="`code-language-${language || 'plain'}`"
|
|
:value="language"
|
|
/>
|
|
</datalist>
|
|
</label>
|
|
<label class="flex cursor-pointer items-center justify-between gap-3 rounded border border-[#edf0f2] bg-[#fafafa] px-3 py-3 text-sm font-semibold text-[#394047]">
|
|
<span>줄번호 표시</span>
|
|
<input
|
|
class="size-4 rounded border-[#c8ced3] text-[#15171a]"
|
|
type="checkbox"
|
|
:checked="panel.showLineNumbers"
|
|
@change="emit('update-code-options', { showLineNumbers: $event.target.checked })"
|
|
>
|
|
</label>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="panel.kind === 'toggle'">
|
|
<div class="admin-editor-block-panel__toggle-settings grid gap-3">
|
|
<button
|
|
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
|
|
:class="panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
|
type="button"
|
|
@click="emit('update-toggle-options', { defaultOpen: true })"
|
|
>
|
|
<span>기본 펼침</span>
|
|
<span v-if="panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
|
|
</button>
|
|
<button
|
|
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
|
|
:class="!panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
|
type="button"
|
|
@click="emit('update-toggle-options', { defaultOpen: false })"
|
|
>
|
|
<span>기본 닫힘</span>
|
|
<span v-if="!panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
|
|
</button>
|
|
</div>
|
|
</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"
|
|
:class="panel.selectedImageIndex === imageIndex ? 'admin-editor-block-panel__media-row--selected' : ''"
|
|
>
|
|
<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>
|
|
<p v-else class="text-[11px] font-normal text-[#8e9cac]">
|
|
캡션을 비우면 이미지 아래에 아무 것도 표시하지 않습니다.
|
|
</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>
|
|
|
|
<style scoped>
|
|
.admin-editor-block-panel__media-row--selected {
|
|
border-color: #2eb6ea;
|
|
box-shadow: 0 0 0 2px rgba(46, 182, 234, 0.18);
|
|
}
|
|
</style>
|