From ad386441713af8236e312e0df0fee0e52e31559c Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 20 Jul 2026 12:37:53 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EA=B3=BC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminEditorBlockPanel.vue | 242 ++++++++++++++++++ components/admin/AdminMarkdownEditor.vue | 5 +- .../content/ContentMarkdownRenderer.vue | 25 +- components/content/ProseImage.vue | 17 ++ docs/changelog.md | 27 ++ docs/deploy.md | 2 +- docs/history.md | 10 + docs/map.md | 8 +- docs/spec.md | 11 +- docs/update.md | 33 +++ lib/markdown-content-normalizer.js | 7 +- lib/markdown-image.js | 62 ++++- package-lock.json | 4 +- package.json | 2 +- pages/admin/index.vue | 95 +++++-- pages/admin/pages/index.vue | 182 ++++++++++++- pages/admin/posts/index.vue | 62 +++-- server/repositories/content-repository.js | 73 ++++-- .../routes/admin/api/analytics/summary.get.js | 13 +- 19 files changed, 788 insertions(+), 92 deletions(-) diff --git a/components/admin/AdminEditorBlockPanel.vue b/components/admin/AdminEditorBlockPanel.vue index 9a1d1a8..df0aca1 100644 --- a/components/admin/AdminEditorBlockPanel.vue +++ b/components/admin/AdminEditorBlockPanel.vue @@ -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>} 직접 입력으로 열어둔 이미지 너비 상태 */ +const customImageWidthKeys = ref(new Set()) +/** @type {import('vue').Ref>} 직접 너비 입력 중인 원본 문자열 */ +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 })" > + +