Skip to content

Commit

Permalink
Quill editor 적용 (#38)
Browse files Browse the repository at this point in the history
* New : QuillEditor, Quill-mention, Dompurify 추가

* New : Quill에디터 구현

* New : Quill에디터 구현

* Refactor : 사용하지 않는 모듈 제거

* New : Quill에디터 적용

* Minor : 브라우저 기본스타일 제거

* New : 태그 리스트를 검색하는 쿼리 추가
  • Loading branch information
jobkaeHenry authored Nov 14, 2023
1 parent ad23935 commit f22ee31
Show file tree
Hide file tree
Showing 9 changed files with 785 additions and 151 deletions.
317 changes: 250 additions & 67 deletions client/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
"axios": "^1.6.0",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.4",
"isomorphic-dompurify": "^1.9.0",
"next": "14.0.0",
"quill-mention": "^4.1.0",
"react": "^18",
"react-dom": "^18",
"react-intersection-observer": "^9.5.3",
"react-quill": "^2.0.0",
"sharp": "^0.32.6",
"zustand": "^4.4.6"
},
Expand Down
87 changes: 11 additions & 76 deletions client/src/app/(protectedRoute)/new-post/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,9 @@
"use client";

import {
AppBar,
Box,
Button,
ButtonBase,
Container,
IconButton,
Paper,
TextField,
Toolbar,
Tooltip,
Typography,
} from "@mui/material";

import GoBackIcon from "@/assets/icons/GoBackIcon.svg";
import { Box, Container, Paper, Tooltip } from "@mui/material";

import { useRouter } from "next/navigation";
import { ChangeEvent, useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import HOME from "@/const/clientPath";
import CameraIcon from "@/assets/icons/CameraIcon.svg";
import PinIcon from "@/assets/icons/PinIcon.svg";
Expand All @@ -34,6 +20,7 @@ import SearchAlcoholInput from "@/components/newpost/SearchAlcoholInput";
import CustomAppbar from "@/components/CustomAppbar";
import SquareIconButton from "@/components/SquareIconButton";
import PreviewImageByURL from "@/components/PreviewImageByURL";
import NewPostTextEditor from "@/components/newpost/NewPostTextEditor";

export default function NewpostPage() {
const { setLoading } = useGlobalLoadingStore();
Expand All @@ -49,7 +36,6 @@ export default function NewpostPage() {

const [alcoholNo, setAlcoholNo] =
useState<NewPostRequestAlCohol["alcoholNo"]>();
const [userTypedTag, setUserTypedTag] = useState<string>("");

const [file, setFile] = useState<File>();
const [fileUrl, setFileUrl] = useState<string | ArrayBuffer | null>();
Expand All @@ -65,12 +51,6 @@ export default function NewpostPage() {
reader.onloadend = () => setFileUrl(reader.result);
}, [file]);

const changeHadler = ({
target,
}: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormValue((prev) => ({ ...prev, [target.name]: target.value }));
};

const { mutateAsync: newPostHandler } = useNewPostMutation();
const { mutateAsync: attachFileHandler } = useNewAttachMutation();
const { mutateAsync: deletePostHandler } = useDeletePostMutation();
Expand Down Expand Up @@ -128,60 +108,15 @@ export default function NewpostPage() {
{/* 검색창 */}
<SearchAlcoholInput setAlcoholNo={setAlcoholNo} />
{/* 내용 */}
<TextField
id="filled-multiline-flexible"
placeholder="입력해주세요"
multiline
name={"postContent"}
onChange={(e) => {
e.target.value.length <= 200 && changeHadler(e);
}}
value={formValue.postContent}
rows={6}
<NewPostTextEditor
onContentChange={({ content, tagList }) =>
setFormValue((prev) => ({
...prev,
postContent: content,
tagList,
}))
}
/>
{/* 총길이 카운터 */}
<Typography variant="label" sx={{ textAlign: "right" }}>
{formValue.postContent!.length} /{" "}
<Typography variant="label" color="primary.main" component="span">
200자
</Typography>
</Typography>
{/* 태그폼 */}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{formValue.tagList!.map((tag) => {
return (
<Typography variant="label" key={tag}>
#{tag}
</Typography>
);
})}
</Box>
<Box
component="form"
onSubmit={(e) => {
e.preventDefault();
setFormValue((prev) => {
if (!userTypedTag || prev.tagList?.includes(userTypedTag)) {
setUserTypedTag("");
return prev;
}
return {
...prev,
tagList: [...(prev?.tagList ?? []), userTypedTag],
};
});
setUserTypedTag("");
}}
sx={{ display: "flex", gap: 1 }}
>
<TextField
onChange={({ target }) => setUserTypedTag(target.value)}
value={userTypedTag}
size="small"
sx={{ flexShrink: 1 }}
/>
<Button type="submit">태그 추가</Button>
</Box>
{/* 파일 미리보기 */}
{fileUrl && <PreviewImageByURL fileUrl={fileUrl} />}
{/* 버튼 그룹 */}
Expand Down
94 changes: 94 additions & 0 deletions client/src/components/newpost/NewPostTextEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";
import React, { useCallback, useState, memo, useEffect } from "react";
import ReactQuill from "react-quill";
import "./quill.core.css";
import "quill-mention";
import "./quill.mention.css";
import { Typography } from "@mui/material";
import { sanitize } from "isomorphic-dompurify";

interface NewPostTextEditorInterface {
onContentChange: (props: { content: string; tagList: string[] }) => void;
}

const NewPostTextEditor = ({ onContentChange }: NewPostTextEditorInterface) => {
const [mentioningValue, setMentioningValue] = useState("");

const [tagList, setTagList] = useState<string[]>([]);

const [content, setContent] = useState("");
const [textLength, setTextLength] = useState(0);

useEffect(() => {
onContentChange({ content: sanitize(content), tagList });
}, [content]);

const modules = {
toolbar: false,
mention: {
allowedChars: /^[A-Za-z-\sÅÄÖåäö]*$/,
mentionDenotationChars: ["#"],
source: useCallback(
async (
searchTerm: string,
renderItem: (
arg0: { id: number | string; value: string }[] | undefined,
arg1: any
) => void,
mentionChar: string
) => {
if (mentionChar === "#") {
// 검색중인 태그를 설정
setMentioningValue(searchTerm);
}
if (searchTerm.length === 0 || searchTerm.includes(" ")) {
renderItem([], searchTerm);
} else {
renderItem([{ id: searchTerm, value: searchTerm }], searchTerm);
}
},
[]
),
},
};

return (
<>
<ReactQuill
style={{ height: 300 }}
modules={modules}
placeholder="입력해주세요"
onChange={(content, _d, _s, editor) => {
const parsedTags = editor
.getContents()
.filter((op) => op.insert?.mention?.value)
.reduce(
(acc: string[], op) => [...acc, op.insert?.mention?.value],
[]
);
setTagList(parsedTags);
setContent(content);
setTextLength(editor.getLength() - 1);
}}
value={content}
/>
<Typography
variant="label"
color={"text.secondary"}
sx={{ textAlign: "right" }}
>
<Typography
variant="label"
color="primary.main"
fontWeight={"bold"}
component="span"
>
{textLength}
</Typography>{" "}
/ 200자
</Typography>
</>
);
};

export default memo(NewPostTextEditor);
Loading

0 comments on commit f22ee31

Please sign in to comment.