관리자 목록과 이미지 설정 개선

This commit is contained in:
2026-07-20 12:37:53 +09:00
parent cf7d6ca0de
commit ad38644171
19 changed files with 788 additions and 92 deletions

View File

@@ -38,6 +38,24 @@ const emit = defineEmits([
'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: '파랑',
@@ -193,6 +211,190 @@ const panelMeta = computed(() => {
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
})
}
/**
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
* @param {FocusEvent} event - focusout 이벤트
@@ -505,6 +707,46 @@ const onPanelFocusOut = (event) => {
@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"

View File

@@ -1776,7 +1776,7 @@ const createImageMarkdown = (image) => serializeImageMarkdown(image)
/**
* 마크다운 이미지 줄과 단독 이미지 URL 줄을 같은 이미지 데이터로 해석한다.
* @param {string} line - 원본 마크다운 줄
* @returns {{ url: string, width: string, caption: string, useAlt: boolean }|null} 이미지 데이터
* @returns {{ url: string, width: string, pixelWidth: number|null, caption: string, useAlt: boolean }|null} 이미지 데이터
*/
const parseEditorImageLine = (line) => {
const image = parseImageMarkdownLine(line)
@@ -1794,6 +1794,7 @@ const parseEditorImageLine = (line) => {
return {
url: trimmed,
width: 'regular',
pixelWidth: null,
caption: '',
useAlt: false
}
@@ -2041,7 +2042,7 @@ const replaceActiveMediaImages = (images) => {
/**
* 현재 미디어 블록의 특정 이미지를 수정한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Partial<{ alt: string, url: string, width: string }>} patch - 변경 값
* @param {Partial<{ alt: string, url: string, width: string, pixelWidth: number|null }>} patch - 변경 값
* @returns {void}
*/
const updateActiveMediaImage = (imageIndex, patch) => {

View File

@@ -169,6 +169,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
ordered: options.ordered || false,
listNumbers: Array.isArray(options.listNumbers) ? options.listNumbers : [],
width: options.width || 'regular',
pixelWidth: Math.round(Number(options.pixelWidth) || 0) || null,
images: options.images || [],
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
calloutEmojiEnabled: options.calloutEmojiEnabled === true,
@@ -712,7 +713,8 @@ const parseMarkdownBlocks = (markdown) => {
const startLine = index
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, {
url: trimmedLine,
width: 'regular'
width: 'regular',
pixelWidth: null
}), startLine, startLine))
index += 1
continue
@@ -1729,6 +1731,17 @@ const getQuoteLineEntries = (block) => {
}))
}
/**
* IME·브라우저 자동 목록 보정으로 생긴 인용 접두를 정리한다.
* @param {string} value - 인용 본문
* @returns {string} 정리된 본문
*/
const normalizeQuoteEditableValue = (value) => {
return String(value ?? '').replace(/\r/g, '').split('\n').map((line) => {
return line.replace(/^>\.\s?/, '')
}).join('\n')
}
/**
* 코드 블록 줄 번호 목록을 만든다.
* @param {Object} block - 코드 블록
@@ -2460,7 +2473,11 @@ const buildQuoteBlockLines = (block, payload) => {
* @returns {void}
*/
const onQuoteBlockCommit = (block, payload) => {
commitInlineBlockLines(block, buildQuoteBlockLines(block, payload))
const normalizedPayload = typeof payload === 'object' && payload !== null
? { ...payload, value: normalizeQuoteEditableValue(payload.value) }
: normalizeQuoteEditableValue(payload)
commitInlineBlockLines(block, buildQuoteBlockLines(block, normalizedPayload))
}
/**
@@ -2486,7 +2503,7 @@ const onQuoteBlockInsertBelow = (block, payload) => {
*/
const onQuoteBlockConvertToParagraph = (block, payload) => {
const { value } = normalizeCommitPayload(payload)
const lines = String(value ?? '').replace(/\r/g, '').split('\n')
const lines = normalizeQuoteEditableValue(value).split('\n')
const replacementLines = lines.length ? lines : ['']
pendingFocusLine.value = block.meta.startLine
@@ -3306,6 +3323,7 @@ onBeforeUnmount(() => {
:alt="getImageAltAttribute(block)"
:caption="getImageDisplayCaption(block)"
:variant="block.width"
:pixel-width="block.pixelWidth"
expandable
@activate="openContentLightbox(block.id)"
/>
@@ -3318,6 +3336,7 @@ onBeforeUnmount(() => {
:alt="getImageAltAttribute(block)"
:caption="getImageDisplayCaption(block)"
:variant="block.width"
:pixel-width="block.pixelWidth"
expandable
@activate="openContentLightbox(block.id)"
/>

View File

@@ -31,6 +31,10 @@ const props = defineProps({
type: String,
default: 'regular'
},
pixelWidth: {
type: Number,
default: null
},
expandable: {
type: Boolean,
default: false
@@ -44,6 +48,18 @@ const currentSrc = ref(props.src)
const hasRenderableSrc = computed(() => String(currentSrc.value || '').trim().length > 0)
const currentSrcset = computed(() => currentSrc.value === props.src ? props.srcset : '')
const figureStyle = computed(() => {
const width = Math.round(Number(props.pixelWidth) || 0)
if (!width) {
return undefined
}
return {
maxWidth: `${width}px`,
width: '100%'
}
})
const errorLabel = computed(() => {
const trimmed = String(props.fallbackSrc || props.src || '').trim()
@@ -99,6 +115,7 @@ const activateImage = () => {
<template>
<figure
class="prose-image mb-2.5"
:style="figureStyle"
:class="{
'prose-image--wide lg:-mx-10 lg:max-w-none': variant === 'wide',
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'