Skip to content

Commit aa472b8

Browse files
authored
Merge pull request #234 from react-challengers/feat/text-editor-change
Feat/text-editor-change
2 parents b62ebc0 + 79a9c27 commit aa472b8

File tree

11 files changed

+211
-139
lines changed

11 files changed

+211
-139
lines changed

Components/CreatePost/Post/Post.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
postTags,
1717
postTitle,
1818
postTitleBackgroundColor,
19+
postId,
1920
} from "@/lib/recoil";
2021
import supabase from "@/lib/supabase";
2122
import { useState, useEffect } from "react";
@@ -29,6 +30,7 @@ import ProjectInfo from "./ProjectInfo";
2930
* @TODO user_id 리코일로 관리
3031
*/
3132
const Post: NextPage = () => {
33+
const [isPostId] = useRecoilState(postId);
3234
const [title, setTitle] = useRecoilState(postTitle);
3335
const [subTitle, setSubTitle] = useRecoilState(postSubTitle);
3436
const [titleBackgroundColor, setTitleBackgroundColor] = useRecoilState(
@@ -54,6 +56,7 @@ const Post: NextPage = () => {
5456
const router = useRouter();
5557

5658
const newPostRow = {
59+
id: isPostId,
5760
title,
5861
sub_title: subTitle,
5962
title_background_color: titleBackgroundColor,
@@ -162,7 +165,7 @@ const Post: NextPage = () => {
162165
setTag([]);
163166
setIsPublic(true);
164167
setMembers([]);
165-
setContent("프로젝트 내용을 입력해주세요.");
168+
setContent("");
166169
setPostLargeCategory("");
167170
setPostSubCategory("");
168171
};

Components/CreatePost/PostEditor.tsx

Lines changed: 161 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,78 @@
1-
import "@toast-ui/editor/dist/toastui-editor.css";
2-
import "@toast-ui/editor/dist/theme/toastui-editor-dark.css";
3-
import "tui-color-picker/dist/tui-color-picker.css";
4-
import "@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css";
5-
import "@toast-ui/editor/dist/i18n/ko-kr";
6-
import "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css";
7-
import "prismjs/themes/prism.css";
8-
9-
import { Editor } from "@toast-ui/react-editor";
10-
import colorSyntax from "@toast-ui/editor-plugin-color-syntax";
11-
import codeSyntaxHighlightPlugin from "@toast-ui/editor-plugin-code-syntax-highlight";
12-
import Prism from "prismjs";
13-
import { RefObject, useCallback, useEffect } from "react";
1+
import "@uiw/react-md-editor/markdown-editor.css";
2+
import "@uiw/react-markdown-preview/markdown.css";
3+
4+
import { useCallback } from "react";
5+
import dynamic from "next/dynamic";
146
import supabase from "@/lib/supabase";
7+
import { v4 as uuidv4 } from "uuid";
8+
import { useRecoilState, useRecoilValue } from "recoil";
9+
import { postContent as recoilPostContent, postId } from "@/lib/recoil";
1510
import imageCompression from "browser-image-compression";
16-
import { useRecoilState } from "recoil";
17-
import { postContent as recoilPostContent } from "@/lib/recoil";
11+
import type { MDEditorProps } from "@uiw/react-md-editor";
12+
import { NextPage } from "next";
13+
import * as commands from "@uiw/react-md-editor/lib/commands";
1814

1915
/**
16+
* @TODO supabase api utill함수로 사용하도록 변경 필요
17+
* @TODO 이미지 업로드시 링크 열리는 문제 해결 필요
2018
* @TODO storage 삭제 구현 필요
21-
* @TODO uuid flag 꽃아야 함 >> 게시와 임시저장의 용도로 분류
2219
*/
2320

24-
interface PostEditorProps {
25-
editorRef: RefObject<Editor>;
26-
}
21+
const MDEditor = dynamic<MDEditorProps>(() => import("@uiw/react-md-editor"), {
22+
ssr: false,
23+
});
2724

28-
const PostEditor = ({ editorRef }: PostEditorProps) => {
25+
const PostEditor: NextPage = () => {
26+
const isPostId = useRecoilValue(postId);
2927
const [postContent, setPostContent] = useRecoilState(recoilPostContent);
30-
const toolbarItems = [
31-
["heading", "bold", "italic", "strike"],
32-
["hr"],
33-
["ul", "ol", "task"],
34-
["table", "link"],
35-
["image"], // <-- 이미지 추가 툴바
36-
["code"],
37-
["scrollSync"],
38-
];
39-
40-
// 이미지 추가
41-
type HookCallback = (url: string, text?: string) => void;
42-
43-
const addImage = useCallback(async (blob: File, dropImage: HookCallback) => {
44-
const img = await compressImg(blob); // 이미지 압축
45-
if (!img) return;
46-
const url = await uploadImage(img); // 업로드된 이미지 서버 url
47-
if (!url) return;
48-
dropImage(url, `${blob.name}`); // 에디터에 이미지 추가
49-
}, []);
50-
51-
useEffect(() => {
52-
if (editorRef.current) {
53-
const editorIns = editorRef.current.getInstance();
54-
editorIns.removeHook("addImageBlobHook");
55-
editorIns.addHook("addImageBlobHook", addImage);
56-
}
57-
}, [editorRef, addImage]);
58-
59-
// 이미지 업로드
60-
61-
const uploadImage = async (blob: File) => {
62-
try {
63-
const imgPath = crypto.randomUUID();
64-
await supabase.storage.from("post-image").upload(imgPath, blob);
65-
66-
// 이미지 올리기
67-
const urlResult = await supabase.storage
68-
.from("post-image")
69-
.getPublicUrl(imgPath);
70-
return urlResult.data.publicUrl;
71-
} catch (error) {
72-
console.log(error);
73-
return false;
74-
}
75-
};
7628

77-
// //이미지 압축
29+
const onImagePasted = useCallback(
30+
async (
31+
dataTransfer: DataTransfer | any // Drag and Drop API
32+
) => {
33+
const files: File[] = []; // 드래그 앤 드랍으로 가져온 파일들
34+
if (dataTransfer.items) {
35+
for (let index = 0; index < dataTransfer.items.length; index += 1) {
36+
const file = dataTransfer.items[index].getAsFile();
37+
if (!file) return;
38+
files.push(file);
39+
}
40+
} else {
41+
const file = dataTransfer[0];
42+
43+
if (!file) return;
44+
files.push(file);
45+
}
46+
47+
const fileId = uuidv4();
48+
files.map(async (file) => {
49+
const compressedFile = await compressImg(file);
50+
51+
if (!compressedFile) return;
52+
53+
const { data: uploadImg } = await supabase.storage
54+
.from("post-image")
55+
.upload(`${isPostId}/${fileId}`, compressedFile);
56+
if (!uploadImg) return;
57+
58+
const { data: insertedMarkdown } = supabase.storage
59+
.from("post-image")
60+
.getPublicUrl(`${isPostId}/${fileId}`);
61+
if (!insertedMarkdown) return;
62+
63+
const insertString = `![${file.name}](${insertedMarkdown.publicUrl})`;
64+
const resultString = insertToTextArea(insertString);
65+
66+
setPostContent(resultString || "");
67+
});
68+
},
69+
[isPostId, setPostContent]
70+
);
71+
72+
// 이미지 압축
7873
const compressImg = async (blob: File): Promise<File | void> => {
7974
const options = {
80-
maxSize: 1,
75+
maxSizeMB: 1,
8176
initialQuality: 0.55, // initial 0.7
8277
};
8378
const result = await imageCompression(blob, options)
@@ -86,35 +81,109 @@ const PostEditor = ({ editorRef }: PostEditorProps) => {
8681
return result;
8782
};
8883

89-
const handleOnEditorChange = () => {
90-
// 유효성 검사
91-
const editorText = editorRef.current?.getInstance().getMarkdown();
92-
if (editorText === " " || editorText === "" || editorText === undefined) {
93-
return;
84+
// 에디터에 이미지 추가
85+
const insertToTextArea = (intsertString: string) => {
86+
const textarea = document.querySelector("textarea");
87+
if (!textarea) {
88+
return null;
9489
}
95-
// HTML 대신에 Markdown으로 저장합니다.
96-
setPostContent(editorText);
90+
let sentence = textarea.value;
91+
const len = sentence.length;
92+
const pos = textarea.selectionStart;
93+
const end = textarea.selectionEnd;
94+
95+
const front = sentence.slice(0, pos);
96+
const back = sentence.slice(pos, len);
97+
98+
sentence = front + intsertString + back;
99+
100+
textarea.value = sentence;
101+
textarea.selectionEnd = end + intsertString.length;
102+
return sentence;
97103
};
98104

99105
return (
100-
<Editor
101-
ref={editorRef}
102-
initialValue={postContent ?? null}
103-
previewStyle="vertical"
104-
height="600px"
105-
initialEditType="markdown"
106-
useCommandShortcut
107-
toolbarItems={toolbarItems}
108-
language="ko-KR"
109-
plugins={[
110-
colorSyntax,
111-
[codeSyntaxHighlightPlugin, { highlighter: Prism }],
112-
]}
113-
hooks={{
114-
// @ts-ignore
115-
addImageBlobHook: addImage,
106+
// div에 클래스를 적용하여 다크모드를 수동으로 적용할 수 있습니다.
107+
<MDEditor
108+
value={postContent}
109+
onChange={(value) => {
110+
setPostContent(value || "");
111+
}}
112+
height={600}
113+
onPaste={(event) => {
114+
onImagePasted(event.clipboardData);
116115
}}
117-
onChange={() => handleOnEditorChange()}
116+
onDrop={(event) => {
117+
onImagePasted(event.dataTransfer);
118+
}}
119+
textareaProps={{
120+
placeholder: "Fill in your markdown for the coolest of the cool.",
121+
}}
122+
commands={[
123+
commands.bold,
124+
commands.italic,
125+
commands.strikethrough,
126+
commands.hr,
127+
commands.title,
128+
commands.divider,
129+
130+
commands.link,
131+
commands.group([], {
132+
name: "image",
133+
groupName: "image",
134+
icon: (
135+
<svg
136+
fill="#444541"
137+
height="12"
138+
width="12"
139+
version="1.1"
140+
id="Capa_1"
141+
xmlns="http://www.w3.org/2000/svg"
142+
viewBox="0 0 489.4 489.4"
143+
>
144+
<g>
145+
<g>
146+
<path
147+
d="M0,437.8c0,28.5,23.2,51.6,51.6,51.6h386.2c28.5,0,51.6-23.2,51.6-51.6V51.6c0-28.5-23.2-51.6-51.6-51.6H51.6
148+
C23.1,0,0,23.2,0,51.6C0,51.6,0,437.8,0,437.8z M437.8,464.9H51.6c-14.9,0-27.1-12.2-27.1-27.1v-64.5l92.8-92.8l79.3,79.3
149+
c4.8,4.8,12.5,4.8,17.3,0l143.2-143.2l107.8,107.8v113.4C464.9,452.7,452.7,464.9,437.8,464.9z M51.6,24.5h386.2
150+
c14.9,0,27.1,12.2,27.1,27.1v238.1l-99.2-99.1c-4.8-4.8-12.5-4.8-17.3,0L205.2,333.8l-79.3-79.3c-4.8-4.8-12.5-4.8-17.3,0
151+
l-84.1,84.1v-287C24.5,36.7,36.7,24.5,51.6,24.5z"
152+
/>
153+
<path
154+
d="M151.7,196.1c34.4,0,62.3-28,62.3-62.3s-28-62.3-62.3-62.3s-62.3,28-62.3,62.3S117.3,196.1,151.7,196.1z M151.7,96
155+
c20.9,0,37.8,17,37.8,37.8s-17,37.8-37.8,37.8s-37.8-17-37.8-37.8S130.8,96,151.7,96z"
156+
/>
157+
</g>
158+
</g>
159+
</svg>
160+
),
161+
// eslint-disable-next-line react/no-unstable-nested-components
162+
children: (handle: any) => {
163+
return (
164+
<div style={{ width: 200, padding: 10 }}>
165+
<input
166+
type="file"
167+
accept="image/*"
168+
onChange={(e) => onImagePasted(e.target.files)}
169+
/>
170+
<button type="button" onClick={() => handle.close()}>
171+
close
172+
</button>
173+
</div>
174+
);
175+
},
176+
buttonProps: { "aria-label": "Insert image" },
177+
}),
178+
commands.quote,
179+
commands.code,
180+
commands.divider,
181+
182+
commands.unorderedListCommand,
183+
commands.orderedListCommand,
184+
commands.checkedListCommand,
185+
commands.divider,
186+
]}
118187
/>
119188
);
120189
};

Components/Detail/DetailArticle.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,11 @@ import {
88
import supabase from "@/lib/supabase";
99
import getTextColorByBackgroundColor from "@/utils/detail/getTextColorByBackgroundColor";
1010
import { useQueryClient } from "@tanstack/react-query";
11-
import dynamic from "next/dynamic";
1211
import { useRouter } from "next/router";
1312
import { useEffect, useState } from "react";
1413
import { ClimbingBoxLoader } from "react-spinners";
1514
import styled from "styled-components";
16-
17-
const Viewer = dynamic(() => import("@/Components/Detail/DetailContent"), {
18-
ssr: false,
19-
});
15+
import DetailContent from "./DetailContent";
2016

2117
const DetailArticle = () => {
2218
const {
@@ -208,7 +204,7 @@ const DetailArticle = () => {
208204
/>
209205
</DetailContentsSide>
210206
<DetailContentsMain>
211-
{content && <Viewer content={content} />}
207+
{content && <DetailContent content={content} />}
212208
</DetailContentsMain>
213209
</DetailContentsContainer>
214210
<RelatedProject />

Components/Detail/DetailContent.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1-
import "@toast-ui/editor/dist/toastui-editor-viewer.css";
2-
import "prismjs/themes/prism.css";
3-
import "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css";
1+
import "@uiw/react-md-editor/markdown-editor.css";
2+
import "@uiw/react-markdown-preview/markdown.css";
43

5-
import { Viewer } from "@toast-ui/react-editor";
6-
import codeSyntaxHighlightPlugin from "@toast-ui/editor-plugin-code-syntax-highlight";
7-
import Prism from "prismjs";
4+
import dynamic from "next/dynamic";
5+
import styled from "styled-components";
6+
7+
const MarkdownPreview = dynamic(() => import("@uiw/react-markdown-preview"), {
8+
ssr: false,
9+
});
810

911
interface DetailContentProps {
1012
content: string;
1113
}
1214

1315
const DetailContent = ({ content }: DetailContentProps) => {
14-
return (
15-
<Viewer
16-
initialValue={content}
17-
plugins={[[codeSyntaxHighlightPlugin, { highlighter: Prism }]]}
18-
/>
19-
);
16+
return <PreviewContent source={content} />;
2017
};
2118

19+
const PreviewContent = styled(MarkdownPreview)`
20+
width: 60rem;
21+
padding: 1rem;
22+
`;
23+
2224
export default DetailContent;

Components/MyPage/UserInfoContainer/UserInfoContainer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import supabase from "@/lib/supabase";
22
import Image, { StaticImageData } from "next/image";
33
import { ChangeEvent, useState } from "react";
44
import styled from "styled-components";
5+
import { v4 as uuidv4 } from "uuid";
56
import convertEase64ToFile from "@/utils/commons/convertBase64ToFile";
67
import { useUserProfile } from "@/hooks/query";
78
import { useInput } from "@/hooks/common";
@@ -42,7 +43,7 @@ const UserInfoContainer = () => {
4243
};
4344

4445
const uploadImage = async (file: File) => {
45-
const imgPath = crypto.randomUUID();
46+
const imgPath = uuidv4();
4647
try {
4748
await supabase.storage.from("post-image").upload(imgPath, file);
4849
const { data } = await supabase.storage

0 commit comments

Comments
 (0)