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

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"