test #35
@@ -22,7 +22,7 @@
|
||||
|
||||
## 작업 계획 문서 규칙 (docs)
|
||||
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
|
||||
- 동일한 세션에서 문서에 내용 추가가 필요하면 동일한 문서에 작성한다.
|
||||
- **기능 추가 중 발생한 오류 수정이나 보완 작업은 새로운 문서를 만들지 않고, 기존 기능 추가 계획 문서에 내용을 추가한다.**
|
||||
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
|
||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||
- 파일명 예시: `20260101_구글계정으로로그인.md`
|
||||
|
||||
38
docs/20260227_시리즈커버이미지크롭기능추가.md
Normal file
38
docs/20260227_시리즈커버이미지크롭기능추가.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 20260227_시리즈커버이미지크롭기능추가.md
|
||||
|
||||
## 구현할 내용
|
||||
- [x] `ContentSeriesList.vue`에 `cropperjs`를 활용한 이미지 크롭 기능 추가
|
||||
- [x] 1:1.5 비율(aspectRatio: 1/1.5) 지원
|
||||
- [x] 가로 해상도 최대 1000px 제한 로직 추가 (1000px 미만은 원본 크기 유지)
|
||||
- [x] 크롭 다이얼로그 및 관련 상태 관리 로직 구현
|
||||
- [x] 크롭 후 `_isCropped` 플래그를 통한 중복 처리 방지
|
||||
- [x] 크롭 다이얼로그 내 이미지 표시 영역 높이 제한 및 스타일 수정
|
||||
- [x] 큰 이미지가 표시될 때 다이얼로그 밖으로 나가지 않도록 `max-height` 조정
|
||||
- [x] 시리즈 이미지 크롭 비율을 A4 비율(210:297)로 수정
|
||||
|
||||
## 검증 기록
|
||||
### 1차 구현
|
||||
- **무엇을**: `ContentSeriesList.vue` 시리즈 등록/수정 시 커버 이미지 크롭 기능 추가
|
||||
- **왜**: 시리즈 커버 이미지의 비율을 1:1.5로 강제하고, 고해상도 이미지 업로드 시 가로 1000px로 리사이징하여 서버 부하 및 레이아웃 깨짐을 방지하기 위함
|
||||
- **어떻게**:
|
||||
- `cropperjs` 라이브러리를 도입하여 `aspectRatio: 1 / 1.5` 설정
|
||||
- `cropImage` 메서드에서 `canvas.width`가 1000px을 초과할 경우에만 리사이징 수행
|
||||
- `file._isCropped` 플래그를 사용하여 크롭 완료 후 `v-file-input` 모델 변경 시 발생하는 `imageAdd` 중복 호출 방지
|
||||
- `npm run lint`를 통해 코드 스타일 검증 완료
|
||||
|
||||
### 2차 수정
|
||||
- **무엇을**: `ContentSeriesList.vue`의 크롭퍼 이미지 컨테이너 스타일 수정
|
||||
- **왜**: 이미지가 너무 클 경우 다이얼로그를 벗어나 스크롤을 해야만 '크롭 완료' 버튼을 누를 수 있는 불편함이 있음.
|
||||
- **어떻게**:
|
||||
- `.cropper-wrapper`의 `max-height`를 `500px`에서 `70vh`로 변경하여 브라우저 높이에 대응하도록 수정.
|
||||
- `npm run lint` 실행 결과 이상 없음 확인.
|
||||
+
|
||||
+### 3차 수정
|
||||
+- **무엇을**: 시리즈 커버 이미지 크롭 비율 수정 (1:1.5 -> A4 비율)
|
||||
+- **왜**: 시리즈 이미지 규격을 A4 비율(210:297)로 통일하기 위함
|
||||
+- **어떻게**:
|
||||
+ - `ContentSeriesList.vue`에서 `aspectRatio`를 `210 / 297`로 수정
|
||||
+ - 크롭 다이얼로그 타이틀을 "이미지 크롭 (A4 비율)"으로 변경
|
||||
+ - 리사이징 시 높이 계산 방식을 `MAX_WIDTH * (297 / 210)`으로 수정
|
||||
+ - 목록의 커버 이미지 `aspect-ratio`를 `210/297`로 수정
|
||||
+ - `npm run lint` 실행 결과 이상 없음 확인
|
||||
@@ -337,6 +337,43 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog
|
||||
v-model="show_cropper_dialog"
|
||||
max-width="800px"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>이미지 크롭 (A4 비율)</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="cropper-wrapper">
|
||||
<img
|
||||
ref="cropper_image"
|
||||
:src="cropper_image_url"
|
||||
alt="Cropper Image"
|
||||
class="cropper-image"
|
||||
>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="grey 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>
|
||||
|
||||
<v-dialog
|
||||
v-model="is_loading"
|
||||
max-width="400px"
|
||||
@@ -356,6 +393,8 @@
|
||||
<script>
|
||||
import * as api from '@/api/audio_content_series';
|
||||
import Draggable from "vuedraggable";
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
|
||||
export default {
|
||||
name: "ContentSeriesList",
|
||||
@@ -369,6 +408,9 @@ export default {
|
||||
series_list: [],
|
||||
selected_series: null,
|
||||
series_genre_list: [],
|
||||
show_cropper_dialog: false,
|
||||
cropper_image_url: '',
|
||||
cropper: null,
|
||||
page: 1,
|
||||
total_page: 0,
|
||||
days_of_week: [
|
||||
@@ -400,13 +442,63 @@ export default {
|
||||
imageAdd(payload) {
|
||||
const file = payload;
|
||||
if (file) {
|
||||
this.series.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: 210 / 297,
|
||||
viewMode: 1,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.series.cover_image_url = null
|
||||
this.series.cover_image = null
|
||||
}
|
||||
},
|
||||
|
||||
cancelCropper() {
|
||||
this.show_cropper_dialog = false
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy()
|
||||
this.cropper = null
|
||||
}
|
||||
this.cropper_image_url = ''
|
||||
this.series.cover_image = null
|
||||
},
|
||||
|
||||
cropImage() {
|
||||
const canvas = this.cropper.getCroppedCanvas()
|
||||
let finalCanvas = canvas
|
||||
|
||||
const MAX_WIDTH = 1000
|
||||
if (canvas.width > MAX_WIDTH) {
|
||||
const height = MAX_WIDTH * (297 / 210)
|
||||
const resizeCanvas = document.createElement('canvas')
|
||||
resizeCanvas.width = MAX_WIDTH
|
||||
resizeCanvas.height = height
|
||||
const ctx = resizeCanvas.getContext('2d')
|
||||
ctx.drawImage(canvas, 0, 0, MAX_WIDTH, height)
|
||||
finalCanvas = resizeCanvas
|
||||
}
|
||||
|
||||
finalCanvas.toBlob((blob) => {
|
||||
const file = new File([blob], 'cover_image.png', {type: 'image/png'})
|
||||
file._isCropped = true
|
||||
this.series.cover_image = file
|
||||
this.series.cover_image_url = URL.createObjectURL(blob)
|
||||
this.show_cropper_dialog = false
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy()
|
||||
this.cropper = null
|
||||
}
|
||||
}, 'image/png')
|
||||
},
|
||||
|
||||
notifyError(message) {
|
||||
this.$dialog.notify.error(message)
|
||||
},
|
||||
@@ -742,6 +834,16 @@ export default {
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
aspect-ratio: 1/1.3;
|
||||
aspect-ratio: 210/297;
|
||||
}
|
||||
|
||||
.cropper-wrapper {
|
||||
max-height: 70vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user