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" ;
14
6
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" ;
15
10
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" ;
18
14
19
15
/**
16
+ * @TODO supabase api utill함수로 사용하도록 변경 필요
17
+ * @TODO 이미지 업로드시 링크 열리는 문제 해결 필요
20
18
* @TODO storage 삭제 구현 필요
21
- * @TODO uuid flag 꽃아야 함 >> 게시와 임시저장의 용도로 분류
22
19
*/
23
20
24
- interface PostEditorProps {
25
- editorRef : RefObject < Editor > ;
26
- }
21
+ const MDEditor = dynamic < MDEditorProps > ( ( ) => import ( "@uiw/react-md-editor" ) , {
22
+ ssr : false ,
23
+ } ) ;
27
24
28
- const PostEditor = ( { editorRef } : PostEditorProps ) => {
25
+ const PostEditor : NextPage = ( ) => {
26
+ const isPostId = useRecoilValue ( postId ) ;
29
27
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
- } ;
76
28
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 = `` ;
64
+ const resultString = insertToTextArea ( insertString ) ;
65
+
66
+ setPostContent ( resultString || "" ) ;
67
+ } ) ;
68
+ } ,
69
+ [ isPostId , setPostContent ]
70
+ ) ;
71
+
72
+ // 이미지 압축
78
73
const compressImg = async ( blob : File ) : Promise < File | void > => {
79
74
const options = {
80
- maxSize : 1 ,
75
+ maxSizeMB : 1 ,
81
76
initialQuality : 0.55 , // initial 0.7
82
77
} ;
83
78
const result = await imageCompression ( blob , options )
@@ -86,35 +81,109 @@ const PostEditor = ({ editorRef }: PostEditorProps) => {
86
81
return result ;
87
82
} ;
88
83
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 ;
94
89
}
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 ;
97
103
} ;
98
104
99
105
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 ) ;
116
115
} }
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
+ ] }
118
187
/>
119
188
) ;
120
189
} ;
0 commit comments