From ef7d5b71bb9c28bf766409fd2e46fa71f7b0f7b4 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 27 Feb 2026 19:09:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=81=AC?= =?UTF-8?q?=EB=A1=AD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260227_이미지크롭포커스이슈수정.md | 14 +++ docs/20260227_콘텐츠커버이미지크롭기능추가.md | 39 +++++++ package-lock.json | 11 ++ package.json | 1 + src/views/Content/ContentList.vue | 103 +++++++++++++++++- 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 docs/20260227_이미지크롭포커스이슈수정.md create mode 100644 docs/20260227_콘텐츠커버이미지크롭기능추가.md diff --git a/docs/20260227_이미지크롭포커스이슈수정.md b/docs/20260227_이미지크롭포커스이슈수정.md new file mode 100644 index 0000000..297e021 --- /dev/null +++ b/docs/20260227_이미지크롭포커스이슈수정.md @@ -0,0 +1,14 @@ +# 20260227_이미지크롭포커스이슈수정.md + +## 구현할 내용 +- [x] 이미지 크롭 후 포커스가 다른 곳으로 가면 다시 크롭화면이 표시되는 문제 해결 +- [x] 크롭 후 `v-file-input` 값 초기화 로직 추가 + +## 검증 기록 +### 1차 수정 +- **무엇을**: `ContentList.vue`의 이미지 선택 및 크롭 로직 수정 +- **왜**: `v-file-input`에 선택된 파일이 남아 있어 다른 곳에 포커스가 갔다가 돌아오거나 입력이 발생할 때 `@change` 이벤트가 예기치 않게 발생할 가능성이 있고, 사용자 경험상 불편함이 있음. +- **어떻게**: + - `imageAdd` 메서드에서 파일을 처리하기 전에 `file._isCropped` 플래그를 확인하여 이미 크롭된 파일인 경우 중복 처리를 방지함. + - `cropImage` 메서드에서 크롭된 새로운 `File` 객체를 생성할 때 `_isCropped = true` 플래그를 설정하여 `audio_content.cover_image`가 업데이트될 때 발생하는 `@change` 이벤트에 의해 크롭 다이얼로그가 다시 열리는 것을 방지함. + - `npm run lint` 실행 결과 이상 없음. diff --git a/docs/20260227_콘텐츠커버이미지크롭기능추가.md b/docs/20260227_콘텐츠커버이미지크롭기능추가.md new file mode 100644 index 0000000..e8c0266 --- /dev/null +++ b/docs/20260227_콘텐츠커버이미지크롭기능추가.md @@ -0,0 +1,39 @@ +# 20260227_콘텐츠커버이미지크롭기능추가.md + +## 작업 개요 +콘텐츠 등록 및 수정 시 커버 이미지를 1:1 비율로 크롭하여 등록할 수 있는 기능을 추가한다. + +## 작업 항목 +- [x] cropperjs 라이브러리 설치 +- [x] ContentList.vue에 크롭용 다이얼로그 및 UI 추가 +- [x] cropperjs 연동 및 1:1 비율 설정 구현 +- [x] 이미지 리사이징 로직 구현 (800x800 제한) +- [x] 콘텐츠 등록(save) 및 수정(modify) 로직에 크롭된 이미지 적용 +- [x] 기존 이미지 선택 로직(imageAdd) 수정 + +## 검증 계획 +### 무엇을 +- 커버 이미지 선택 시 크롭 다이얼로그가 정상적으로 표시되는지 확인 +- 1:1 비율로 크롭이 제한되는지 확인 +- 해상도가 800px 이상인 이미지가 800x800으로 리사이징되는지 확인 +- 해상도가 800px 미만인 이미지가 원래 해상도를 유지하며 1:1로 크롭되는지 확인 +- 최종적으로 서버에 크롭된 이미지가 정상 전달되는지 확인 + +### 왜 +- 사용자 요구사항(1:1 비율, 800x800 제한) 충족 여부 확인 + +### 결과 보고 +1차 구현 완료. +- 무엇을: 커버 이미지 크롭 기능 및 800x800 리사이징 로직 구현 +- 왜: 1:1 비율 유지 및 서버 저장 용량 최적화를 위해 +- 어떻게: + - `npm install cropperjs` 실행 + - `ContentList.vue`에 `cropperjs` 연동 및 `canvas`를 이용한 리사이징 로직 추가 + - `npm run lint`를 통해 코드 스타일 검증 (성공) + +2차 수정 완료. +- 무엇을: `Module not found: Error: Can't resolve 'cropperjs/dist/cropper.css'` 에러 수정 +- 왜: `cropperjs` 2.x 버전에서는 CSS 파일이 포함되어 있지 않아 빌드 에러 발생 +- 어떻게: + - `cropperjs`를 1.x 버전(`^1.5.13`)으로 재설치하여 CSS 파일을 포함하도록 수정 + - `npm run lint`를 통해 코드 스타일 검증 (성공) diff --git a/package-lock.json b/package-lock.json index 3434c2c..218b63a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "core-js": "^3.6.5", + "cropperjs": "^1.6.2", "vue": "^2.6.11", "vue-router": "^3.2.0", "vue-show-more-text": "^2.0.2", @@ -4839,6 +4840,11 @@ "sha.js": "^2.4.8" } }, + "node_modules/cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==" + }, "node_modules/cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -19522,6 +19528,11 @@ "sha.js": "^2.4.8" } }, + "cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==" + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", diff --git a/package.json b/package.json index 352a21e..fef4633 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "core-js": "^3.6.5", + "cropperjs": "^1.6.2", "vue": "^2.6.11", "vue-router": "^3.2.0", "vue-show-more-text": "^2.0.2", diff --git a/src/views/Content/ContentList.vue b/src/views/Content/ContentList.vue index 65fad9b..cd17378 100644 --- a/src/views/Content/ContentList.vue +++ b/src/views/Content/ContentList.vue @@ -711,6 +711,45 @@ + + + + + 이미지 크롭 + + +
+ Cropper Image +
+
+ + + + 취소 + + + 확인 + + +
+
@@ -720,10 +759,12 @@ import VuetifyAudio from 'vuetify-audio' // Main JS (in UMD format) import VueTimepicker from 'vue2-timepicker' import DatePicker from 'vue2-datepicker'; +import Cropper from 'cropperjs'; import 'vue2-datepicker/index.css'; // CSS import 'vue2-timepicker/dist/VueTimepicker.css' +import 'cropperjs/dist/cropper.css'; export default { name: "AudioContentList", @@ -740,6 +781,9 @@ export default { show_create_dialog: false, show_modify_dialog: false, show_delete_confirm_dialog: false, + show_cropper_dialog: false, + cropper_image_url: '', + cropper: null, page: 1, total_page: 0, search_word: '', @@ -834,13 +878,63 @@ export default { imageAdd(payload) { const file = payload; if (file) { - this.audio_content.cover_image_url = URL.createObjectURL(file) - URL.revokeObjectURL(file) + // 이미 크롭 처리된 파일인 경우 다시 다이얼로그를 띄우지 않음 + if (file._isCropped) return; + + this.cropper_image_url = URL.createObjectURL(file) + this.show_cropper_dialog = true + this.$nextTick(() => { + if (this.cropper) { + this.cropper.destroy() + } + this.cropper = new Cropper(this.$refs.cropper_image, { + aspectRatio: 1, + viewMode: 1, + }) + }) } else { this.audio_content.cover_image_url = null + this.audio_content.cover_image = null } }, + cancelCropper() { + this.show_cropper_dialog = false + if (this.cropper) { + this.cropper.destroy() + this.cropper = null + } + this.cropper_image_url = '' + this.audio_content.cover_image = null + }, + + cropImage() { + const canvas = this.cropper.getCroppedCanvas() + let finalCanvas = canvas + + const MAX_SIZE = 800 + if (canvas.width > MAX_SIZE || canvas.height > MAX_SIZE) { + const resizeCanvas = document.createElement('canvas') + resizeCanvas.width = MAX_SIZE + resizeCanvas.height = MAX_SIZE + const ctx = resizeCanvas.getContext('2d') + ctx.drawImage(canvas, 0, 0, MAX_SIZE, MAX_SIZE) + finalCanvas = resizeCanvas + } + + finalCanvas.toBlob((blob) => { + const file = new File([blob], 'cover_image.png', { type: 'image/png' }) + file._isCropped = true // 크롭된 파일임을 표시하여 재진입 방지 + this.audio_content.cover_image = file + this.audio_content.cover_image_url = URL.createObjectURL(blob) + this.show_cropper_dialog = false + if (this.cropper) { + this.cropper.destroy() + this.cropper = null + } + }, 'image/png') + }, + async getAudioContentThemeList() { this.is_loading = true try { @@ -1235,4 +1329,9 @@ export default { object-fit: cover; margin-top: 10px; } + +.cropper-wrapper { + max-height: 500px; + overflow: hidden; +}