문제점
당시 저는 에디터가 어떻게 구현되는지, 에디터의 종류는 무엇이 있는지 전혀 모르는 상태였습니다. 또 tiptap을 선택했지만 러닝 커브가 좀 컸다는 것입니다. useEditor, useCurrentEditor, 메뉴바 생성, 컨텐츠 분리 등 다양한 곳에서 모르는 것이 생겼습니다.
고민과 해결
커뮤니티 핵심인 에디터를 구현하기 위해 tiptap 라이브러리를 사용했습니다. 보통 인터넷에서 볼 수 있는 에디터는 rich text editor라고 부르는 것 같기에, 해당 키워드로 접목할 만한 툴이 있을지 찾아보았습니다.
다양한 에디터가 있었지만 그 중 tiptap을 선택하게 된 이유는 ‘커스텀’이 가능하다는 점 때문이었습니다. 다양한 기능과 상세한 문서는 물론이고 내가 원하는 기능을 붙일 수도 있다니 멀리 봤을 때 가장 이상적이라고 생각했습니다. 심지어 Collaborative editing, 여러 사람이 동시에 편집하는 기능도 제공하고 있었습니다.
특히나 문제였던 것은 메뉴입니다. 굵게하기
버튼을 하나 만들기 위해서 editor에 달려있는 다양한 메소드를 이해해야 했습니다. 이미지를 추가하는 것도 상당히 복잡한 과정이 필요했습니다. (알고 보니 해당 익스텐션을 제공하고 있었습니다.)
이미지 버튼 추가하기
<button>
<label
style={{ display: 'flex', alignContent: 'center', cursor: 'pointer' }}
htmlFor="image"
>
<BsCardImage />
</label>
<input
style={{ display: 'none' }}
id="image"
type="file"
accept="image/*"
onChange={handleFileChange}
/>
</button>
handleFileChange
onChange 핸들러 제작 - 파일에 변화가 있을 때, 이미지를 firebase에 업로드하고 반환된 downloadUrl을 프리뷰 url로 설정합니다.
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const firebaseUrl = await getFirebaseUrl(file);
setUrl(firebaseUrl!);
e.target.value = '';
};
이미지를 에디터에 나타내기 - 이미지를 업로드하면 커서가 해당 이미지로 가있어서 바로 텍스트를 입력할 수 없었습니다. 이는 UX에 치명적일 거라 생각했습니다.
setImage
로 업로드합니다.useEffect(() => {
if (url) {
editor.chain().focus().setImage({ src: url }).run();
const { to } = editor.state.selection;
const tr = editor.state.tr.insert(to, editor.schema.nodes.paragraph.create());
editor.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, to + 1)));
editor.view.focus();
}
}, [url, editor]);
에디터가 하나로 합쳐져 있었기 때문에, 제목과 본문으로 분리가 필요했습니다.
// extensions 배열에 추가할 커스텀 노드들
const DocumentWithTitle = Document.extend({
content: 'title block+',
});
const CustomHeading = Heading.extend({
parseHTML() {
return this.options.levels.map((level) => ({
tag: `h${level}`,
attrs: { level: adjustLevel(level) },
}));
},
addInputRules() {
return this.options.levels.map((level) => {
return textblockTypeInputRule({
find: new RegExp(`^(#{1,${level}})\\s$`),
type: this.type,
getAttributes: {
level: adjustLevel(level),
},
});
});
},
});
const Title = Node.create<TitleProps>({
name: 'title',
addOptions() {
return {
level: 1,
HTMLAttributes: {},
};
},
content: 'text*',
marks: '',
group: 'block',
defining: true,
renderHTML({ HTMLAttributes }) {
const level = this.options.level;
return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});
글 수정하기는 기존 글을 서버에서 받아와야 합니다.
useArticle
이라는 react query 훅이 이미 있기 때문에 굳이 이중으로 상태 관리를 할 필요 없이 서버 상태 하나로 관리하는 게 나을 것 같았습니다.아래와 같은 방식으로 고민하고 풀어나갔습니다.
두 개의 다른 에디터 페이지를 어떤 방식으로 나눌 것인가? (1)
id
가 필요합니다.usePublish.ts
import ArticleService, { ArticleRequest } from '@/api/articleService';
import { uploadImageToFirebase } from '@/hooks/useImageConvert';
import { useMutation } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { editorState } from '../atoms/editor.atom';
type PublishConfig = {
mode: 'write' | 'edit';
articleId?: number;
};
const api = new ArticleService();
const usePublish = ({ mode, articleId }: PublishConfig) => {
const [newContent, setNewContent] = useState('');
const [newTitle, setNewTitle] = useState('');
const { content, isAnonymity } = useRecoilValue(editorState);
const navigate = useNavigate();
useEffect(() => {
const processContent = async () => {
const parser = new DOMParser();
const document = parser.parseFromString(content, 'text/html');
const titleEl = document.querySelector('h1');
if (titleEl && titleEl.textContent) {
setNewTitle(titleEl.textContent);
titleEl.remove();
}
// TODO: 에러 핸들링 과정 추가
// TODO: 해당 과정 Promise.all()로 업데이트
const images = document.querySelectorAll('img');
for (const img of images) {
const blob = await fetch(img.src).then((res) => res.blob());
const firebaseUrl = await uploadImageToFirebase(blob);
img.src = firebaseUrl;
}
setNewContent(document.body.innerHTML);
};
processContent();
}, [content]);
const mutation = useMutation({
mutationKey: ['publish'],
mutationFn: (articleData: ArticleRequest) => {
return mode === 'write' ? api.post(articleData) : api.edit(articleData);
},
onSuccess: () => {
navigate(`/community/${articleId}`);
},
onError: (error) => {
console.error(error);
},
});
const handleSubmit = () => {
const articleData: ArticleRequest = { title: newTitle, content: newContent, isAnonymity };
if (mode === 'edit') {
articleData.id = articleId;
}
mutation.mutate(articleData);
};
const handleCancel = () => {
if (mode === 'write') {
navigate('/community');
} else {
navigate(`/community/${articleId}`);
}
};
return { handleSubmit, handleCancel, status: mutation.status };
};
export default usePublish;
문제점
mutationFn
, onSuccess
, handleSubmit
, handleCancel
등 모든 함수 내부에서 mode에 따른 분기 처리를 해줘야 했습니다.mutationFn
의 return 값이 달라서 onSuccess
의 파라미터에 data: <TData>
를 넣을 수 없었습니다.두 개의 다른 에디터 페이지를 어떤 방식으로 나눌 것인가? (채택)
DefaultEditor.tsx
// 일부 코드입니다.
interface DefaultEditorProps {
article?: Article;
}
const DefaultEditor = ({ article }: DefaultEditorProps) => {
const content = article ? `<h1>${article.title}</h1> ${article.content}` : `<p></p>`;
if (!editor) return null;
return (
<div className="editor-main">
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
);
};
export default DefaultEditor;
EditEditor.tsx
import DefaultEditor from '@/pages/editor/components/editor';
import { useArticle } from '../../../articleDetail/hooks/useArticle';
const EditEditor = () => {
const { data: article, isLoading } = useArticle();
if (isLoading) return null;
return <DefaultEditor article={article} />;
};
export default EditEditor;
아쉬운 점