사용하고 있는 패키지 매니저로 설치하면 된다.
yarn add react-quill
또는 npm i react-quill
import React, {
ReactChild,
ReactFragment,
RefObject,
useMemo,
useState,
} from 'react';
import ReactQuill, { Quill } from 'react-quill';
import 'react-quill/dist/quill.snow.css';
const formats = [
'font',
'header',
'bold',
'italic',
'underline',
'strike',
'blockquote',
'list',
'bullet',
'indent',
'link',
'align',
'color',
'background',
'size',
'h1',
];
export default function QuillEditor = () => {
const [values, setValues] = useState();
const modules = useMemo(() => {
return {
toolbar: {
container: [
[{ size: ['small', false, 'large', 'huge'] }],
[{ align: [] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[
{
color: [],
},
{ background: [] },
],
],
},
};
}, []);
return(
<ReactQuill
theme="snow"
modules={modules}
formats={formats}
onChange={setValues}
/>
)
}
toolbar
를 id로 선언해준다. export const CustomToolbar = () => (
<div id="toolbar">
<span className="ql-formats">
<select className="ql-size" defaultValue="medium">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="huge">Huge</option>
</select>
<select className="ql-header">
<option value="1">Header 1</option>
<option value="2">Header 2</option>
<option value="3">Header 3</option>
<option value="4">Header 4</option>
<option value="5">Header 5</option>
<option value="6">Header 6</option>
</select>
</span>
<span className="ql-formats">
<button className="ql-bold" />
<button className="ql-italic" />
<button className="ql-underline" />
<button className="ql-strike" />
<button className="ql-blockquote" />
</span>
<span className="ql-formats">
<select className="ql-color" />
<select className="ql-background" />
</span>
<span className="ql-formats">
<button className="ql-image" />
<button className="ql-video" />
</span>
<span className="ql-formats">
<button className="ql-clean" />
</span>
</div>
);
커스텀한 toolbar를 사용하기 위해 React Quill 컴포넌트에서 선언해준 modules의 toolbar 객체의 container에 부여한 id값을 지정한다.
const modules = useMemo(() => {
return {
toolbar: {
container: "#toolbar",
},
};
}, []);
에디터에 글자를 입력하고, 각각 다르게 글자 색깔을 색상했다. 추가적으로 반갑습니다~
부분에는 strong 효과를 주었다.
스타일이 지정된 html 태그
값이 정상적으로 찍히는걸 확인할 수 있다.<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(values),
}}
style={{
marginTop: '30px',
overflow: 'hidden',
whiteSpace: 'pre-wrap',
}}
/>
정상적으로 잘 출력되었다!
여기에서
dangerouslySetInnerHTML
은 브라우저 DOM에서 innerHTML을 사용하기 위한 React의 대체 방법이다. 일반적으로 코드에서 HTML을 설정하는 것은 사이트 간 스크립팅 공격에 쉽게 노출될 수 있기 때문에 위험하다. 따라서 React에서 직접 HTML을 설정할 수는 있지만, 위험하다는 것을 상기시키기 위dangerouslySetInnerHTML
을 작성하고 __html 키로 객체를 전달해야 한다.
dangerouslySetInnerHTML
을 이용할때 script가 포함되어 있으면 스크립팅 공격에 취약해지는데, 이러한 위험을 막기 위해 DOMPurify
를 사용해줬다.
이미지를 업로드 하고 콘솔에 출력해보면 엄청나게 긴 문자열이 찍히는걸 확인할 수 있다. 이렇게 base64 형태로 백엔드 서버에 저장하면 서버에 엄청난 부하가 걸릴 것이다. 만약 업로드해야할 사진이 100장이라면....?
그래서 다른 방법으로 이미지를 처리해야 한다.
useUploadFile
을 react query로 만들었고 handleImageUpload
함수를 통해 file을 인자로 받아서 useUploadFile에 전달해줘서 업로드 하는 방식으로 구현했다. // 파일 업로드를 위한 커스텀 훅
const uploadFileMutation = useUploadFile();
async function handleImageUpload(file: File) {
if (!file) {
// 파일 선택이 취소된 경우 사용자에게 알려주기
alert('파일이 선택되지 않았습니다.');
return;
}
if (quillRef.current) {
try {
const result = await uploadFileMutation.mutateAsync(file);
const editor = quillRef.current.getEditor();
const range = editor.getSelection(true);
// range가 있는지를 검사한다.
// 만약 range가 null 이거나 undefined인경우 당연히 삽입할 대상의 위치가
// 없는것이므로 이미지가 삽입되지 않는다...!
if (range) {
editor.insertEmbed(range.index, 'image', result.displayUrl);
} else {
alert('에디터에 포커스를 맞추고 다시 시도해주세요.');
}
} catch (error) {
alert('이미지 업로드 중 오류가 발생했습니다. 다시 시도해주세요.');
console.error('Error:', error);
}
}
}
마지막으로 아까 만든 react quill 컴포넌트에 선언한 modules에 handlers 옵션을 추가해주면 된다.
가독성과 유지보수를 위해 handlers의 image에 바인딩된 함수는 커스텀훅으로 분리할 수도 있다.
const modules = useMemo(() => {
return {
toolbar: {
container: [
[{ size: ['small', false, 'large', 'huge'] }],
[{ align: [] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[
{
color: [],
},
{ background: [] },
],
['image'],
],
// ✅ 추가된 handlers 옵션
handlers: {
image: () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.addEventListener('change', () => {
// 파일을 하나씩 업로드
const file = input.files && input.files[0];
handleImageUpload(file);
});
input.click();
},
},
},
};
}, []);
이전에는 개별 파일의 0번째 인덱스에 접근하여 파일을 하나씩 업로드했던 반면, 다중 이미지를 업로드 하기 위해서는 multiple
속성을 생성한 input 요소에 attribute 시켜준다.
const multiImageHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.setAttribute('multiple', '');
input.addEventListener('change', () => {
if (input.files) {
handleMultipleImagesUpload(input.files);
}
});
input.click();
};
이번에는 multiImageHandler
함수를 생성하고, 툴바의 handers의 image에 함수를 바인딩해주자.
...
handlers: {
image: multiImageHandler,
},
...
multiImageHandler의 handleMultipleImagesUpload
는 감지된 이미지 정보들
을 파라미터로 받아서 이를 각각 순회하고 해당 값을 처리하는 로직인데, 나의 경우에는 전역상태관리 라이브러리인 jotai
를 이용해서 이미지 자체를 아톰에 세팅했다.
// 아톰 생성해주기
export const multipleAtom = atom<NewFileDto[]>([]);
// setter로만 사용할것이기 때문에 useSetAtom 훅으로 아톰 선언하기
const setMultiImages = useSetAtom(multipleAtom);
const handleMultipleImagesUpload = useCallback(
async (files: FileList) => {
for (const file of files) {
try {
const res = await uploadFileMutation.mutateAsync(file);
// 원하시는 로직을 작성하면 될거같다.
setUploadImageStatus(res.displayUrl);
setMultiImages(prev => [...prev, res]);
} catch (error) {
console.error(error);
}
}
},
[uploadFileMutation, setMultiImages],
);