852 lines
32 KiB
Vue
852 lines
32 KiB
Vue
<script setup>
|
|
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
|
import {
|
|
CALLOUT_BACKGROUND_OPTIONS,
|
|
CALLOUT_BACKGROUND_SWATCHES,
|
|
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',
|
|
'update-inline-color'
|
|
])
|
|
|
|
/** @type {number} 단일 이미지 직접 너비 최소값 */
|
|
const IMAGE_WIDTH_MIN = 120
|
|
/** @type {number} 단일 이미지 직접 너비 최대값 */
|
|
const IMAGE_WIDTH_MAX = 1600
|
|
/** @type {Array<{ label: string, value: string, pixelWidth: number|null }>} 단일 이미지 대표 너비 옵션 */
|
|
const IMAGE_WIDTH_PRESET_OPTIONS = [
|
|
{ label: '전체 너비(자동)', value: 'auto', pixelWidth: null },
|
|
{ label: '갤러리 2장 1칸(356px)', value: '356', pixelWidth: 356 },
|
|
{ label: '갤러리 3장 1칸(235px)', value: '235', pixelWidth: 235 },
|
|
{ label: '갤러리 3장 2칸(477px)', value: '477', pixelWidth: 477 },
|
|
{ label: '직접 입력', value: 'custom', pixelWidth: null }
|
|
]
|
|
|
|
/** @type {import('vue').Ref<Set<string>>} 직접 입력으로 열어둔 이미지 너비 상태 */
|
|
const customImageWidthKeys = ref(new Set())
|
|
/** @type {import('vue').Ref<Record<string, string>>} 직접 너비 입력 중인 원본 문자열 */
|
|
const imageWidthDraftValues = ref({})
|
|
|
|
const backgroundLabels = {
|
|
gray: '회색',
|
|
blue: '파랑',
|
|
green: '초록',
|
|
yellow: '노랑',
|
|
red: '빨강',
|
|
purple: '보라'
|
|
}
|
|
|
|
const backgroundSwatches = {
|
|
...CALLOUT_BACKGROUND_SWATCHES
|
|
}
|
|
|
|
const inlineColorSwatches = {
|
|
gray: '#6b7280',
|
|
blue: '#0055ff',
|
|
green: '#16ae68',
|
|
yellow: '#a16207',
|
|
red: '#e60000',
|
|
purple: '#8800ff'
|
|
}
|
|
|
|
/** @type {Array<{ label: string, value: string }>} 코드 블록 언어 셀렉트 옵션 */
|
|
const CODE_LANGUAGE_OPTIONS = [
|
|
{ label: '일반 텍스트', value: '' },
|
|
{ label: 'JavaScript', value: 'javascript' },
|
|
{ label: 'HTML', value: 'html' },
|
|
{ label: 'CSS', value: 'css' },
|
|
{ label: 'Vue', value: 'vue' },
|
|
{ label: 'JSON', value: 'json' },
|
|
{ label: 'Bash', value: 'bash' },
|
|
{ label: 'Markdown', value: 'markdown' },
|
|
{ label: 'SQL', value: 'sql' }
|
|
]
|
|
|
|
/** @type {number|null} 패널 내부 포인터 조작 상태 해제 타이머 */
|
|
let panelPointerInsideTimer = null
|
|
/** @type {boolean} 패널 내부 포인터 조작 중 여부 */
|
|
let isPanelPointerInside = false
|
|
|
|
/**
|
|
* 배경 라벨을 반환한다.
|
|
* @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
|
|
|
|
/**
|
|
* 인라인 색상 스와치를 반환한다.
|
|
* @param {string} color - 색상 키
|
|
* @returns {string} CSS 색상
|
|
*/
|
|
const getInlineColorSwatch = (color) => inlineColorSwatches[color] || inlineColorSwatches.gray
|
|
|
|
/**
|
|
* 블록 종류 라벨
|
|
* @returns {string}
|
|
*/
|
|
const panelTitle = computed(() => {
|
|
if (!props.panel) {
|
|
return ''
|
|
}
|
|
|
|
if (props.panel.kind === 'gallery') {
|
|
return '갤러리'
|
|
}
|
|
|
|
if (props.panel.kind === 'inline-color') {
|
|
return '텍스트 색상'
|
|
}
|
|
|
|
if (props.panel.kind === 'html') {
|
|
return 'HTML'
|
|
}
|
|
|
|
if (props.panel.kind === 'link') {
|
|
return '링크'
|
|
}
|
|
|
|
if (props.panel.kind === 'iframe') {
|
|
return 'iframe'
|
|
}
|
|
|
|
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 === 'inline-color') {
|
|
return '선택한 텍스트'
|
|
}
|
|
|
|
if (props.panel.kind === 'html') {
|
|
return '사용자 HTML'
|
|
}
|
|
|
|
if (props.panel.kind === 'link') {
|
|
return 'URL 카드'
|
|
}
|
|
|
|
if (props.panel.kind === 'iframe') {
|
|
return 'sandbox iframe'
|
|
}
|
|
|
|
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 {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @returns {string} 입력 표시 값
|
|
*/
|
|
const getImagePixelWidthInputValue = (imageIndex, image) => {
|
|
const draftKey = getImageWidthStateKey(imageIndex, image)
|
|
|
|
if (Object.prototype.hasOwnProperty.call(imageWidthDraftValues.value, draftKey)) {
|
|
return imageWidthDraftValues.value[draftKey]
|
|
}
|
|
|
|
const width = Math.round(Number(image?.pixelWidth) || 0)
|
|
return width ? String(width) : ''
|
|
}
|
|
|
|
/**
|
|
* 이미지 너비 UI 상태 키를 반환한다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @returns {string} 상태 키
|
|
*/
|
|
const getImageWidthStateKey = (imageIndex, image) => `${imageIndex}:${String(image?.url || '')}`
|
|
|
|
/**
|
|
* 이미지 너비 직접 입력 UI 상태를 저장한다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @param {boolean} enabled - 직접 입력 여부
|
|
* @returns {void}
|
|
*/
|
|
const setImageWidthCustomState = (imageIndex, image, enabled) => {
|
|
const nextKeys = new Set(customImageWidthKeys.value)
|
|
const key = getImageWidthStateKey(imageIndex, image)
|
|
|
|
if (enabled) {
|
|
nextKeys.add(key)
|
|
} else {
|
|
nextKeys.delete(key)
|
|
}
|
|
|
|
customImageWidthKeys.value = nextKeys
|
|
}
|
|
|
|
/**
|
|
* 이미지 너비 입력 임시 값을 저장한다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @param {string} value - 입력 문자열
|
|
* @returns {void}
|
|
*/
|
|
const setImageWidthDraftValue = (imageIndex, image, value) => {
|
|
imageWidthDraftValues.value = {
|
|
...imageWidthDraftValues.value,
|
|
[getImageWidthStateKey(imageIndex, image)]: value
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 이미지 너비 입력 임시 값을 지운다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @returns {void}
|
|
*/
|
|
const clearImageWidthDraftValue = (imageIndex, image) => {
|
|
const key = getImageWidthStateKey(imageIndex, image)
|
|
const nextValues = { ...imageWidthDraftValues.value }
|
|
delete nextValues[key]
|
|
imageWidthDraftValues.value = nextValues
|
|
}
|
|
|
|
/**
|
|
* 직접 입력 너비를 허용 범위로 정리한다.
|
|
* @param {unknown} value - 입력 너비
|
|
* @returns {number|null} 정리된 너비
|
|
*/
|
|
const normalizeImageWidthInput = (value) => {
|
|
const width = Math.round(Number(value) || 0)
|
|
|
|
if (!width) {
|
|
return null
|
|
}
|
|
|
|
return Math.min(Math.max(width, IMAGE_WIDTH_MIN), IMAGE_WIDTH_MAX)
|
|
}
|
|
|
|
/**
|
|
* 이미지 너비 대표 옵션 값을 반환한다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @returns {string} 셀렉트 값
|
|
*/
|
|
const getImageWidthPresetValue = (imageIndex, image) => {
|
|
const width = Math.round(Number(image?.pixelWidth) || 0)
|
|
|
|
if (customImageWidthKeys.value.has(getImageWidthStateKey(imageIndex, image))) {
|
|
return 'custom'
|
|
}
|
|
|
|
if (!width) {
|
|
return 'auto'
|
|
}
|
|
|
|
return IMAGE_WIDTH_PRESET_OPTIONS.some((option) => option.pixelWidth === width)
|
|
? String(width)
|
|
: 'custom'
|
|
}
|
|
|
|
/**
|
|
* 이미지 너비 대표 옵션 변경을 전달한다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @param {Event} event - 변경 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const emitImageWidthPresetChange = (imageIndex, image, event) => {
|
|
const value = String(event.target?.value || 'auto')
|
|
const preset = IMAGE_WIDTH_PRESET_OPTIONS.find((option) => option.value === value)
|
|
|
|
if (value === 'custom') {
|
|
setImageWidthCustomState(imageIndex, image, true)
|
|
setImageWidthDraftValue(imageIndex, image, String(Math.round(Number(image?.pixelWidth) || 0) || 356))
|
|
emit('update-media-image', imageIndex, {
|
|
pixelWidth: Math.round(Number(image?.pixelWidth) || 0) || 356
|
|
})
|
|
return
|
|
}
|
|
|
|
setImageWidthCustomState(imageIndex, image, false)
|
|
clearImageWidthDraftValue(imageIndex, image)
|
|
emit('update-media-image', imageIndex, {
|
|
pixelWidth: preset?.pixelWidth || null
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 이미지 너비 입력 변경을 전달한다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @param {Event} event - 입력 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const emitImagePixelWidthChange = (imageIndex, image, event) => {
|
|
const raw = String(event.target?.value || '').trim()
|
|
const width = Math.round(Number(raw) || 0)
|
|
|
|
setImageWidthCustomState(imageIndex, image, true)
|
|
setImageWidthDraftValue(imageIndex, image, raw)
|
|
|
|
if (!raw) {
|
|
emit('update-media-image', imageIndex, {
|
|
pixelWidth: null
|
|
})
|
|
return
|
|
}
|
|
|
|
if (width < IMAGE_WIDTH_MIN || width > IMAGE_WIDTH_MAX) {
|
|
return
|
|
}
|
|
|
|
emit('update-media-image', imageIndex, {
|
|
pixelWidth: width
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 이미지 너비 입력 종료 시 허용 범위로 정리해 전달한다.
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @param {Object} image - 이미지 정보
|
|
* @param {Event} event - 입력 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const commitImagePixelWidthChange = (imageIndex, image, event) => {
|
|
const raw = String(event.target?.value || '').trim()
|
|
const pixelWidth = normalizeImageWidthInput(raw)
|
|
|
|
setImageWidthCustomState(imageIndex, image, true)
|
|
clearImageWidthDraftValue(imageIndex, image)
|
|
emit('update-media-image', imageIndex, {
|
|
pixelWidth
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 패널 내부 포인터 조작 중에는 포커스 이탈로 패널이 닫히지 않게 유지한다.
|
|
* @returns {void}
|
|
*/
|
|
const markPanelPointerInside = () => {
|
|
window.clearTimeout(panelPointerInsideTimer)
|
|
isPanelPointerInside = true
|
|
emit('panel-focus-in')
|
|
panelPointerInsideTimer = window.setTimeout(() => {
|
|
isPanelPointerInside = false
|
|
}, 300)
|
|
}
|
|
|
|
/**
|
|
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
|
|
* @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
|
|
}
|
|
|
|
if (isPanelPointerInside) {
|
|
return
|
|
}
|
|
|
|
emit('panel-focus-out')
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
window.clearTimeout(panelPointerInsideTimer)
|
|
})
|
|
</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"
|
|
@pointerdown.capture="markPanelPointerInside"
|
|
@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 === 'inline-color'">
|
|
<div class="admin-editor-block-panel__inline-color-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="color in QUOTE_BACKGROUND_OPTIONS"
|
|
:key="`inline-color-${color}`"
|
|
class="flex items-center gap-2 rounded border border-[#dce0e5] bg-[#fafafa] px-3 py-2 text-left text-xs font-semibold text-[#657080] transition hover:bg-white"
|
|
type="button"
|
|
@click="emit('update-inline-color', color)"
|
|
>
|
|
<span
|
|
class="size-5 shrink-0 rounded-full border border-black/5"
|
|
:style="{ background: getInlineColorSwatch(color) }"
|
|
aria-hidden="true"
|
|
/>
|
|
<span>{{ getQuoteBackgroundLabel(color) }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="['html', 'link', 'iframe'].includes(panel.kind)">
|
|
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
|
<span class="font-semibold text-[#394047]">
|
|
{{ panel.kind === 'link' ? 'URL' : panel.kind === 'iframe' ? 'iframe 코드' : 'HTML 코드' }}
|
|
</span>
|
|
<textarea
|
|
class="min-h-28 resize-y rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 font-mono text-xs leading-5 text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
|
:value="panel.url"
|
|
:placeholder="panel.kind === 'link' ? 'https://example.com' : panel.kind === 'iframe' ? '<iframe src="https://..."></iframe>' : '<table>...</table>'"
|
|
@input="emit('update-embed-url', $event.target.value)"
|
|
/>
|
|
</label>
|
|
<p v-if="panel.kind === 'html'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
|
본문 HTML은 그대로 표시하되 script, iframe, 이벤트 속성 같은 실행성 코드는 제거합니다. iframe은 /iframe 블록을 사용합니다.
|
|
</p>
|
|
<p v-else-if="panel.kind === 'link'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
|
YouTube·X(트위터)·Mastodon URL은 임베드로, 그 외 외부 URL은 링크 카드로 표시합니다.
|
|
</p>
|
|
<p v-else class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
|
YouTube·YouTube Shorts, X(트위터) 게시물 URL과 외부 서비스 iframe 코드를 지원합니다. iframe은 src 등 허용된 속성만 사용합니다.
|
|
</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="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.title"
|
|
type="text"
|
|
placeholder="주의사항"
|
|
@input="emit('update-callout-options', { title: $event.target.value })"
|
|
>
|
|
</label>
|
|
|
|
<label class="admin-editor-block-panel__callout-icon-toggle flex cursor-pointer items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
|
|
<span class="admin-editor-block-panel__callout-icon-toggle-copy flex min-w-0 items-center gap-3">
|
|
<span class="admin-editor-block-panel__callout-icon-toggle-label font-bold text-[#15171a]">
|
|
아이콘 표시
|
|
</span>
|
|
</span>
|
|
<span class="admin-editor-block-panel__callout-icon-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
|
<input
|
|
class="peer sr-only"
|
|
type="checkbox"
|
|
aria-label="아이콘 표시"
|
|
:checked="panel.calloutEmojiEnabled"
|
|
@change="emit('update-callout-options', { calloutEmojiEnabled: $event.target.checked })"
|
|
>
|
|
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
|
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
|
</span>
|
|
</label>
|
|
|
|
<div
|
|
class="admin-editor-block-panel__callout-icon-options grid gap-2 transition-opacity"
|
|
:class="panel.calloutEmojiEnabled ? '' : 'opacity-45'"
|
|
>
|
|
<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:cursor-not-allowed"
|
|
: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.calloutEmojiEnabled && panel.calloutEmoji === emoji ? 'border-[#15171a] bg-white' : panel.calloutEmojiEnabled ? 'border-[#dce0e5] bg-[#fafafa] hover:bg-white' : 'cursor-not-allowed border-[#dce0e5] bg-[#f4f5f6] text-[#a4adb8]'"
|
|
type="button"
|
|
:disabled="!panel.calloutEmojiEnabled"
|
|
@mousedown.prevent
|
|
@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-5">
|
|
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
|
<span class="font-semibold text-[#394047]">언어</span>
|
|
<AdminSelectInput
|
|
:model-value="panel.language"
|
|
:options="CODE_LANGUAGE_OPTIONS"
|
|
label="코드 블록 언어"
|
|
root-class="admin-editor-block-panel__code-language-select"
|
|
width-class="w-full"
|
|
select-class="bg-[#eff1f2] border-[#d7dde2] text-[#15171a]"
|
|
@update:model-value="emit('update-code-options', { language: $event })"
|
|
/>
|
|
</label>
|
|
<label class="admin-editor-block-panel__code-line-number-toggle flex cursor-pointer items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
|
|
<span class="admin-editor-block-panel__code-line-number-toggle-copy flex min-w-0 items-center gap-3">
|
|
<span class="admin-editor-block-panel__code-line-number-toggle-label font-bold text-[#15171a]">
|
|
줄번호 표시
|
|
</span>
|
|
</span>
|
|
<span class="admin-editor-block-panel__code-line-number-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
|
<input
|
|
class="peer sr-only"
|
|
type="checkbox"
|
|
aria-label="줄번호 표시"
|
|
:checked="panel.showLineNumbers"
|
|
@change="emit('update-code-options', { showLineNumbers: $event.target.checked })"
|
|
>
|
|
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
|
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
|
</span>
|
|
</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>
|
|
<label
|
|
v-if="panel.kind === 'image'"
|
|
class="grid gap-1 text-xs font-semibold text-[#394047]"
|
|
>
|
|
표시 너비
|
|
<span class="relative block">
|
|
<select
|
|
class="w-full appearance-none rounded border border-[#d7dde2] bg-white px-3 py-2 pr-9 text-sm font-normal text-[#15171a] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
|
|
:value="getImageWidthPresetValue(imageIndex, image)"
|
|
@change="emitImageWidthPresetChange(imageIndex, image, $event)"
|
|
>
|
|
<option
|
|
v-for="option in IMAGE_WIDTH_PRESET_OPTIONS"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
</span>
|
|
</label>
|
|
<label
|
|
v-if="panel.kind === 'image' && getImageWidthPresetValue(imageIndex, image) === 'custom'"
|
|
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="getImagePixelWidthInputValue(imageIndex, image)"
|
|
type="number"
|
|
inputmode="numeric"
|
|
:min="IMAGE_WIDTH_MIN"
|
|
:max="IMAGE_WIDTH_MAX"
|
|
step="10"
|
|
placeholder="자동"
|
|
@input="emitImagePixelWidthChange(imageIndex, image, $event)"
|
|
@blur="commitImagePixelWidthChange(imageIndex, image, $event)"
|
|
>
|
|
</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>
|