All checks were successful
LocalNet_front/pipeline/head This commit looks good
317 lines
14 KiB
Vue
317 lines
14 KiB
Vue
<template>
|
|
<div>
|
|
<!-- 툴바 HTML -->
|
|
<div id="toolbar">
|
|
<select class="ql-font" v-model="font">
|
|
<option value="nanum-gothic">나눔고딕</option>
|
|
<option value="d2coding">d2coding</option>
|
|
<option value="consolas">consolas</option>
|
|
<option value="serif">Serif</option>
|
|
<option value="monospace">Monospace</option>
|
|
</select>
|
|
|
|
<select class="ql-size" v-model="fontSize">
|
|
<option value="12px">12px</option>
|
|
<option value="14px">14px</option>
|
|
<option value="16px" selected>16px</option>
|
|
<option value="18px">18px</option>
|
|
<option value="24px">24px</option>
|
|
<option value="32px">32px</option>
|
|
<option value="48px">48px</option>
|
|
</select>
|
|
|
|
<button class="ql-bold">B</button>
|
|
<button class="ql-italic">I</button>
|
|
<button class="ql-underline">U</button>
|
|
|
|
<button class="ql-header" value="1">H1</button>
|
|
<button class="ql-header" value="2">H2</button>
|
|
<button class="ql-header" value="3">H3</button>
|
|
<button class="ql-header" value="4">H4</button>
|
|
<button class="ql-header" value="5">H5</button>
|
|
<button class="ql-header" value="6">H6</button>
|
|
|
|
<button class="ql-list" value="ordered">OL</button>
|
|
<button class="ql-list" value="bullet">UL</button>
|
|
|
|
<button class="ql-align" value="">Left</button>
|
|
<button class="ql-align" value="center">Center</button>
|
|
<button class="ql-align" value="right">Right</button>
|
|
<button class="ql-align" value="justify">Justify</button>
|
|
|
|
<button class="ql-link">Link</button>
|
|
<button class="ql-image">Image</button>
|
|
<button class="ql-video">Video</button>
|
|
<button class="ql-blockquote">Blockquote</button>
|
|
<button class="ql-code-block">Code Block</button>
|
|
</div>
|
|
<!-- 에디터가 표시될 div -->
|
|
<div id="qEditor" ref="editor"></div>
|
|
<!-- Alert 메시지 표시 -->
|
|
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import Quill from 'quill';
|
|
import 'quill/dist/quill.snow.css';
|
|
import $api from '@api';
|
|
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
|
|
import { useToastStore } from '@s/toastStore';
|
|
|
|
const toastStore = useToastStore();
|
|
|
|
const props = defineProps({
|
|
isAlert: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
initialData: {
|
|
type: [String, Object],
|
|
default: () => null,
|
|
},
|
|
});
|
|
|
|
const editor = ref(null); // 에디터 DOM 참조
|
|
const font = ref('nanum-gothic'); // 기본 폰트
|
|
const fontSize = ref('16px'); // 기본 폰트 크기
|
|
const emit = defineEmits(['update:data', 'update:uploadedImgList', 'update:deleteImgIndexList']);
|
|
const uploadedImgList = ref([]); // 에디터에 이미지 첨부시 업데이트 된 파일 인덱스 번호 리스트
|
|
const initImageIndex = ref([]); // 에디터 로드 시 이미지 인덱스 정보
|
|
const deleteImgIndexList = ref([]); // 에디터의 이미지 파일 수정 및 삭제 시 해당 이미지 인덱스 목록
|
|
|
|
onMounted(() => {
|
|
// 툴바에서 선택할 수 있는 폰트 목록 설정
|
|
const Font = Quill.import('formats/font');
|
|
Font.whitelist = ['nanum-gothic', 'd2coding', 'consolas', 'serif', 'monospace'];
|
|
Quill.register(Font, true);
|
|
|
|
// 툴바에서 선택할 수 있는 폰트 크기 목록 설정
|
|
const Size = Quill.import('attributors/style/size');
|
|
Size.whitelist = ['12px', '14px', '16px', '18px', '24px', '32px', '48px'];
|
|
Quill.register(Size, true);
|
|
|
|
// Quill 에디터 인스턴스 생성
|
|
const quillInstance = new Quill(editor.value, {
|
|
theme: 'snow',
|
|
placeholder: '내용을 입력해주세요...',
|
|
modules: {
|
|
toolbar: {
|
|
container: '#toolbar',
|
|
},
|
|
syntax: true,
|
|
},
|
|
});
|
|
|
|
// 폰트와 폰트 크기 설정
|
|
quillInstance.format('font', font.value);
|
|
quillInstance.format('size', fontSize.value);
|
|
|
|
// 텍스트가 변경될 때마다 부모 컴포넌트로 변경된 내용 전달
|
|
quillInstance.on('text-change', () => {
|
|
const delta = quillInstance.getContents(); // Delta 포맷으로 내용 가져오기
|
|
emit('update:data', delta);
|
|
});
|
|
|
|
// 폰트나 폰트 크기가 변경될 때 에디터 스타일 업데이트
|
|
watch([font, fontSize], () => {
|
|
quillInstance.format('font', font.value);
|
|
quillInstance.format('size', fontSize.value);
|
|
});
|
|
|
|
// 이미지 추가 항목 체크
|
|
watch(uploadedImgList, () => {
|
|
emit('update:uploadedImgList', uploadedImgList.value);
|
|
});
|
|
|
|
// 이미지 첨부 리스트가 변경(삭제) 되었을때
|
|
watch(deleteImgIndexList, () => {
|
|
emit('update:deleteImgIndexList', deleteImgIndexList.value);
|
|
});
|
|
|
|
// 초기 데이터가 있을 경우, HTML 형식으로 삽입
|
|
if (props.initialData) {
|
|
quillInstance.setContents(JSON.parse(props.initialData));
|
|
initCheckImageIndex();
|
|
}
|
|
|
|
// 영상 넣기
|
|
quillInstance.getModule('toolbar').addHandler('video', () => {
|
|
const url = prompt('YouTube 영상 URL을 입력하세요:');
|
|
let src = '';
|
|
if (!url || url.trim() == '') return;
|
|
|
|
// 일반 youtube url
|
|
if (url.indexOf('watch?v=') !== -1) {
|
|
src = url.replace('watch?v=', 'embed/');
|
|
|
|
// youtu.be 단축 URL (ex : https://youtu.be/CfiojceAaeQ?si=G7eM56sdDjIEw-Tz)
|
|
} else if (url.indexOf('youtu.be/') !== -1) {
|
|
const videoId = url.split('youtu.be/')[1].split('?')[0];
|
|
src = `https://www.youtube.com/embed/${videoId}`;
|
|
|
|
// iframe 주소
|
|
} else if (url.indexOf('<iframe') !== -1) {
|
|
// DOMParser를 사용하여 embeded url만 추출
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(url, 'text/html');
|
|
const iframeEL = doc.querySelector('iframe');
|
|
src = iframeEL.getAttribute('src');
|
|
} else {
|
|
toastStore.onToast('지원하는 영상 타입 아님', 'e');
|
|
return;
|
|
}
|
|
|
|
const index = quillInstance.getSelection().index;
|
|
quillInstance.insertEmbed(index, 'video', src);
|
|
quillInstance.setSelection(index + 1);
|
|
});
|
|
|
|
// 이미지 업로드 기능 처리
|
|
let imageUrls = new Set(); // 업로드된 이미지 URL을 추적
|
|
quillInstance.getModule('toolbar').addHandler('image', () => {
|
|
selectLocalImage(); // 이미지 버튼 클릭 시 로컬 이미지 선택
|
|
});
|
|
|
|
// 에디터의 텍스트가 변경될 때마다 이미지 처리
|
|
quillInstance.on('text-change', (delta, oldDelta, source) => {
|
|
delta.ops.forEach(op => {
|
|
if (op.insert && typeof op.insert === 'object' && op.insert.image) {
|
|
const imageUrl = op.insert.image; // 이미지 URL 추출
|
|
imageUrls.add(imageUrl); // URL 추가
|
|
} else if (op.delete) {
|
|
checkForDeletedImages(); // 삭제된 이미지 확인
|
|
}
|
|
});
|
|
|
|
checkDeletedImages();
|
|
emit('update:data', quillInstance.getContents());
|
|
});
|
|
|
|
// 로컬 이미지 파일 선택
|
|
async function selectLocalImage() {
|
|
const input = document.createElement('input');
|
|
input.setAttribute('type', 'file');
|
|
input.setAttribute('accept', 'image/*');
|
|
input.click(); // 파일 선택 다이얼로그 열기
|
|
input.onchange = () => {
|
|
const file = input.files[0];
|
|
if (file) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
// 이미지 서버에 업로드 후 URL 받기
|
|
uploadImageToServer(formData)
|
|
.then(data => {
|
|
const uploadImgIdx = data?.fileIndex; // 업로드 된 파일 DB 인덱스
|
|
const serverImageUrl = data?.fileUrl; // 업로드 된 파일 url
|
|
|
|
// 업로드 된 파일 인덱스 (게시글 저장 시 해당 인덱스 번호에 게시글 인덱스를 업데이트)
|
|
if (uploadImgIdx) {
|
|
uploadedImgList.value = [...uploadedImgList.value, uploadImgIdx];
|
|
initImageIndex.value = [...initImageIndex.value, uploadImgIdx];
|
|
}
|
|
|
|
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
|
|
//const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
|
|
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}?imgIndex=${uploadImgIdx}`; // 이미지 경로에 index 정보 추가
|
|
|
|
const range = quillInstance.getSelection();
|
|
quillInstance.insertEmbed(range.index, 'image', fullImageUrl); // 선택된 위치에 이미지 삽입
|
|
|
|
imageUrls.add(fullImageUrl); // 이미지 URL 추가
|
|
})
|
|
.catch(e => {
|
|
toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
// 이미지 서버 업로드
|
|
async function uploadImageToServer(formData) {
|
|
try {
|
|
// Make the POST request to upload the image
|
|
const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
|
|
|
|
// Check if the response contains the expected data
|
|
if (response.data && response.data.data) {
|
|
const imageUrl = response.data.data;
|
|
return imageUrl; // Return the image URL received from the server
|
|
} else {
|
|
throw new Error('Image URL not returned from server');
|
|
}
|
|
} catch (error) {
|
|
// Log detailed error information for debugging purposes
|
|
console.error('Image upload failed:', error);
|
|
|
|
// Handle specific error cases (e.g., network issues, authorization issues)
|
|
if (error.response) {
|
|
// If the error is from the server (e.g., 4xx or 5xx error)
|
|
console.error('Error response:', error.response.data);
|
|
toastStore.onToast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
|
|
} else if (error.request) {
|
|
// If no response is received from the server
|
|
console.error('No response received:', error.request);
|
|
toastStore.onToast('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
|
|
} else {
|
|
// If the error is due to something else (e.g., invalid request setup)
|
|
console.error('Error message:', error.message);
|
|
toastStore.onToast('파일 업로드 중 문제가 발생했습니다. 다시 시도해주세요.', 'e');
|
|
}
|
|
|
|
// Throw the error so the caller knows something went wrong
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// 삭제된 이미지 확인
|
|
function checkForDeletedImages() {
|
|
const editorImages = document.querySelectorAll('#qEditor img');
|
|
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); // 현재 에디터에 있는 이미지들
|
|
|
|
imageUrls.forEach(url => {
|
|
if (!currentImages.has(url)) {
|
|
imageUrls.delete(url);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 초기 에디터 로드 시 이미지 인덱스 정보 추출
|
|
function initCheckImageIndex() {
|
|
const editorImages = document.querySelectorAll('#qEditor img');
|
|
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); // 현재 에디터에 있는 이미지들
|
|
|
|
currentImages.forEach(url => {
|
|
const index = getImgIndex(url);
|
|
if (index) {
|
|
initImageIndex.value.push(Number(index));
|
|
}
|
|
});
|
|
}
|
|
|
|
// 이미지에서 index 정보 추출
|
|
function getImgIndex(url) {
|
|
const params = new URLSearchParams(url.split('?')[1]);
|
|
return params.get('imgIndex');
|
|
}
|
|
|
|
// 에디터 이미지 수정 시 삭제 인덱스 확인
|
|
function checkDeletedImages() {
|
|
const editorImages = document.querySelectorAll('#qEditor img');
|
|
const currentImages = new Set(Array.from(editorImages).map(img => img.src));
|
|
|
|
// init 이미지 인덱스와 수정 된 이미지 값을 비교
|
|
const tempDeleteImgIndex = [...initImageIndex.value];
|
|
currentImages.forEach(url => {
|
|
const imgIndex = getImgIndex(url);
|
|
if (imgIndex) {
|
|
const index = tempDeleteImgIndex.indexOf(imgIndex);
|
|
tempDeleteImgIndex.splice(index, 1);
|
|
}
|
|
});
|
|
deleteImgIndexList.value = tempDeleteImgIndex;
|
|
}
|
|
});
|
|
</script>
|