test #35
14
docs/20260227_이미지크롭포커스이슈수정.md
Normal file
14
docs/20260227_이미지크롭포커스이슈수정.md
Normal file
@@ -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` 실행 결과 이상 없음.
|
||||||
39
docs/20260227_콘텐츠커버이미지크롭기능추가.md
Normal file
39
docs/20260227_콘텐츠커버이미지크롭기능추가.md
Normal file
@@ -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`를 통해 코드 스타일 검증 (성공)
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
"vue-router": "^3.2.0",
|
"vue-router": "^3.2.0",
|
||||||
"vue-show-more-text": "^2.0.2",
|
"vue-show-more-text": "^2.0.2",
|
||||||
@@ -4839,6 +4840,11 @@
|
|||||||
"sha.js": "^2.4.8"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "6.0.5",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
||||||
@@ -19522,6 +19528,11 @@
|
|||||||
"sha.js": "^2.4.8"
|
"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": {
|
"cross-spawn": {
|
||||||
"version": "6.0.5",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
"vue-router": "^3.2.0",
|
"vue-router": "^3.2.0",
|
||||||
"vue-show-more-text": "^2.0.2",
|
"vue-show-more-text": "^2.0.2",
|
||||||
|
|||||||
@@ -711,6 +711,45 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="show_cropper_dialog"
|
||||||
|
max-width="800px"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>
|
||||||
|
이미지 크롭
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="cropper-wrapper">
|
||||||
|
<img
|
||||||
|
ref="cropper_image"
|
||||||
|
:src="cropper_image_url"
|
||||||
|
alt="Cropper Image"
|
||||||
|
style="max-width: 100%;"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="blue darken-1"
|
||||||
|
text
|
||||||
|
@click="cancelCropper"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="blue darken-1"
|
||||||
|
text
|
||||||
|
@click="cropImage"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -720,10 +759,12 @@ import VuetifyAudio from 'vuetify-audio'
|
|||||||
// Main JS (in UMD format)
|
// Main JS (in UMD format)
|
||||||
import VueTimepicker from 'vue2-timepicker'
|
import VueTimepicker from 'vue2-timepicker'
|
||||||
import DatePicker from 'vue2-datepicker';
|
import DatePicker from 'vue2-datepicker';
|
||||||
|
import Cropper from 'cropperjs';
|
||||||
|
|
||||||
import 'vue2-datepicker/index.css';
|
import 'vue2-datepicker/index.css';
|
||||||
// CSS
|
// CSS
|
||||||
import 'vue2-timepicker/dist/VueTimepicker.css'
|
import 'vue2-timepicker/dist/VueTimepicker.css'
|
||||||
|
import 'cropperjs/dist/cropper.css';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "AudioContentList",
|
name: "AudioContentList",
|
||||||
@@ -740,6 +781,9 @@ export default {
|
|||||||
show_create_dialog: false,
|
show_create_dialog: false,
|
||||||
show_modify_dialog: false,
|
show_modify_dialog: false,
|
||||||
show_delete_confirm_dialog: false,
|
show_delete_confirm_dialog: false,
|
||||||
|
show_cropper_dialog: false,
|
||||||
|
cropper_image_url: '',
|
||||||
|
cropper: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
total_page: 0,
|
total_page: 0,
|
||||||
search_word: '',
|
search_word: '',
|
||||||
@@ -834,13 +878,63 @@ export default {
|
|||||||
imageAdd(payload) {
|
imageAdd(payload) {
|
||||||
const file = payload;
|
const file = payload;
|
||||||
if (file) {
|
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 {
|
} else {
|
||||||
this.audio_content.cover_image_url = null
|
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() {
|
async getAudioContentThemeList() {
|
||||||
this.is_loading = true
|
this.is_loading = true
|
||||||
try {
|
try {
|
||||||
@@ -1235,4 +1329,9 @@ export default {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cropper-wrapper {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user