1- import { LoadingOverlay } from "@/components/atoms/LoadingOverlay" ;
2- import { useImageCreate } from "@/lib/api/image" ;
3- import { useTheme } from "@/lib/hooks/useTheme" ;
4- import { getUuid } from "@/lib/utils/utils" ;
5- import MDEditor , { commands , MDEditorProps } from "@uiw/react-md-editor" ;
6- import { LucideSmilePlus } from "lucide-react" ;
7- import { useEffect , useRef , useState } from "react" ;
8- import rehypeSanitize from "rehype-sanitize" ;
9- import { toast } from "sonner" ;
10- import EmojiPicker , { EmojiClickData , Theme } from "emoji-picker-react" ;
1+ import { LoadingOverlay } from "@/components/atoms/LoadingOverlay"
2+ import { useImageCreate } from "@/lib/api/image"
3+ import { useTheme } from "@/lib/hooks/useTheme"
4+ import { getUuid } from "@/lib/utils/utils"
5+ import MDEditor , { commands , MDEditorProps } from "@uiw/react-md-editor"
6+ import { LucideSmilePlus } from "lucide-react"
7+ import { useEffect , useMemo , useRef , useState } from "react"
8+ import rehypeSanitize from "rehype-sanitize"
9+ import { toast } from "sonner"
10+ import EmojiPicker , { EmojiClickData , Theme } from "emoji-picker-react"
1111
12- export function MarkdownCombo ( { value = "" , onChange, ...props } : MDEditorProps ) {
13- const { theme } = useTheme ( ) ;
12+ type Props = MDEditorProps
1413
15- const [ uploading , setUploading ] = useState ( false ) ;
16- const imageCreate = useImageCreate ( ) ;
14+ type Selection = { start : number ; end : number }
1715
18- const editorRef = useRef < HTMLDivElement > ( null ) ;
19- const textareaRef = useRef < HTMLTextAreaElement | null > ( null ) ;
16+ const isHttpUrl = ( text : string ) => / ^ h t t p s ? : \/ \/ \S + $ / i. test ( text . trim ( ) )
2017
21- const [ emojiOpen , setEmojiOpen ] = useState ( false ) ;
18+ const canInsertTextWithExecCommand = ( ) => {
19+ // execCommand is deprecated, but is still supported by most browser and works with the browser's undo stack
20+ if ( typeof document === "undefined" ) return false
21+ if ( typeof document . execCommand !== "function" ) return false
22+ if ( typeof document . queryCommandSupported !== "function" ) return true
23+ return document . queryCommandSupported ( "insertText" )
24+ }
25+
26+ export function MarkdownCombo ( { value = "" , onChange, ...props } : Props ) {
27+ const { theme } = useTheme ( )
28+
29+ const [ uploading , setUploading ] = useState ( false )
30+ const [ emojiOpen , setEmojiOpen ] = useState ( false )
31+
32+ const imageCreate = useImageCreate ( )
33+
34+ const editorRef = useRef < HTMLDivElement > ( null )
35+ const textareaRef = useRef < HTMLTextAreaElement | null > ( null )
36+
37+ const selectionRef = useRef < Selection > ( { start : 0 , end : 0 } )
2238
23- const selectionRef = useRef < { start : number ; end : number } > ( { start : 0 , end : 0 } ) ;
2439 const updateSelection = ( ) => {
25- const ta = textareaRef . current ;
26- if ( ! ta ) return ;
40+ const ta = textareaRef . current
41+ if ( ! ta ) return
42+
2743 selectionRef . current = {
2844 start : ta . selectionStart ?? 0 ,
2945 end : ta . selectionEnd ?? 0 ,
30- } ;
31- } ;
32- const getSelection = ( ) : string => {
33- updateSelection ( ) ;
34- const { start, end } = selectionRef . current ;
35- return value . slice ( start , end ) ;
46+ }
3647 }
48+
49+ const getSelectedText = ( ) => {
50+ updateSelection ( )
51+
52+ const { start, end } = selectionRef . current
53+
54+ return value . slice ( start , end )
55+ }
56+
3757 const setSelection = ( start : number , end : number ) => {
3858 requestAnimationFrame ( ( ) => {
39- const ta = textareaRef . current ;
40- if ( ! ta ) return ;
41- ta . focus ( ) ;
42- ta . setSelectionRange ( start , end ) ;
43- selectionRef . current = { start : start , end : end } ;
44- } ) ;
45- } ;
59+ const ta = textareaRef . current
60+ if ( ! ta ) return
61+ ta . focus ( )
62+ ta . setSelectionRange ( start , end )
63+ selectionRef . current = { start, end }
64+ } )
65+ }
66+
4667 const replaceSelection = ( replaceText : string ) => {
47- updateSelection ( ) ;
48- const { start, end } = selectionRef . current ;
68+ updateSelection ( )
69+
70+ const { start, end } = selectionRef . current
71+
72+ const ta = textareaRef . current
73+
74+ if ( ! ta ) {
75+ const nextValue = value . slice ( 0 , start ) + replaceText + value . slice ( end )
76+ onChange ?.( nextValue )
77+ const nextCursor = start + replaceText . length
78+ setSelection ( nextCursor , nextCursor )
79+
80+ return
81+ }
82+
83+ ta . focus ( )
84+ ta . setSelectionRange ( start , end )
4985
50- const nextValue = value . slice ( 0 , start ) + replaceText + value . slice ( end ) ;
51- onChange ?.( nextValue ) ;
86+ const execOk = canInsertTextWithExecCommand ( ) && document . execCommand ( "insertText" , false , replaceText )
5287
53- const nextCursor = start + replaceText . length ;
54- setSelection ( nextCursor , nextCursor ) ;
88+ if ( execOk ) {
89+ updateSelection ( )
90+
91+ return
92+ }
93+
94+ ta . setRangeText ( replaceText , start , end , "end" )
95+ selectionRef . current = {
96+ start : start + replaceText . length ,
97+ end : start + replaceText . length ,
98+ }
99+
100+ // Let react know the input was changed
101+ try {
102+ ta . dispatchEvent (
103+ new InputEvent ( "input" , {
104+ bubbles : true ,
105+ inputType : "insertText" ,
106+ data : replaceText ,
107+ } )
108+ )
109+ } catch {
110+ ta . dispatchEvent ( new Event ( "input" , { bubbles : true } ) )
111+ }
55112 }
56113
57114 useEffect ( ( ) => {
58- const root = editorRef . current ;
59- if ( ! root ) return ;
115+ const root = editorRef . current
116+ if ( ! root ) return
60117
61- const ta = root . querySelector < HTMLTextAreaElement > ( ".w-md-editor-text-input" ) ;
62- textareaRef . current = ta ;
118+ const ta = root . querySelector < HTMLTextAreaElement > ( ".w-md-editor-text-input" )
119+ textareaRef . current = ta
120+ if ( ! ta ) return
63121
64- if ( ! ta ) return ;
122+ const onAny = ( ) => updateSelection ( )
65123
66- const onAny = ( ) => updateSelection ( ) ;
67- ta . addEventListener ( "keyup" , onAny ) ;
68- ta . addEventListener ( "mouseup" , onAny ) ;
69- ta . addEventListener ( "select" , onAny ) ;
70- ta . addEventListener ( "focus" , onAny ) ;
124+ ta . addEventListener ( "keyup" , onAny )
125+ ta . addEventListener ( "mouseup" , onAny )
126+ ta . addEventListener ( "select" , onAny )
127+ ta . addEventListener ( "focus" , onAny )
71128
72- updateSelection ( ) ;
129+ updateSelection ( )
73130
74131 return ( ) => {
75- ta . removeEventListener ( "keyup" , onAny ) ;
76- ta . removeEventListener ( "mouseup" , onAny ) ;
77- ta . removeEventListener ( "select" , onAny ) ;
78- ta . removeEventListener ( "focus" , onAny ) ;
79- } ;
80- } , [ ] ) ;
132+ ta . removeEventListener ( "keyup" , onAny )
133+ ta . removeEventListener ( "mouseup" , onAny )
134+ ta . removeEventListener ( "select" , onAny )
135+ ta . removeEventListener ( "focus" , onAny )
136+ }
137+ } , [ ] )
81138
82139 useEffect ( ( ) => {
83- if ( ! emojiOpen ) return ;
140+ if ( ! emojiOpen ) return
84141
85142 const onDocMouseDown = ( e : MouseEvent ) => {
86- const root = editorRef . current ;
87- if ( ! root ) return ;
88- if ( ! root . contains ( e . target as Node ) ) setEmojiOpen ( false ) ;
89- } ;
143+ const root = editorRef . current
144+ if ( ! root ) return
145+ if ( ! root . contains ( e . target as Node ) ) setEmojiOpen ( false )
146+ }
90147
91148 const onKeyDown = ( e : KeyboardEvent ) => {
92- if ( e . key === "Escape" ) setEmojiOpen ( false ) ;
93- } ;
149+ if ( e . key === "Escape" ) setEmojiOpen ( false )
150+ }
151+
152+ document . addEventListener ( "mousedown" , onDocMouseDown )
153+ document . addEventListener ( "keydown" , onKeyDown )
94154
95- document . addEventListener ( "mousedown" , onDocMouseDown ) ;
96- document . addEventListener ( "keydown" , onKeyDown ) ;
97155 return ( ) => {
98- document . removeEventListener ( "mousedown" , onDocMouseDown ) ;
99- document . removeEventListener ( "keydown" , onKeyDown ) ;
100- } ;
101- } , [ emojiOpen ] ) ;
156+ document . removeEventListener ( "mousedown" , onDocMouseDown )
157+ document . removeEventListener ( "keydown" , onKeyDown )
158+ }
159+ } , [ emojiOpen ] )
102160
103161 useEffect ( ( ) => {
104- const container = editorRef . current ;
105- if ( ! container ) return ;
106-
107- const handlePaste = async ( e : ClipboardEvent ) => {
108- // handle pasting http urls (for auto hyperlink)
109- const pasteText = e . clipboardData ?. getData ( "text/plain" ) ;
110- if ( pasteText ?. startsWith ( "https://" ) ) {
111-
112- // only do special handling if there was text selected
113- // otherwise use the normal handling, so the undo/redo is preserved
114- const selectedText = getSelection ( ) ;
115- if ( selectedText ) {
116- e . preventDefault ( ) ;
117- const replacementText = `[${ selectedText } ](${ pasteText } )` ;
118- replaceSelection ( replacementText ) ;
119- return ;
162+ const container = editorRef . current
163+ if ( ! container ) return
164+
165+ const handlePaste = ( e : ClipboardEvent ) => {
166+ const pasteText = e . clipboardData ?. getData ( "text/plain" ) ?? ""
167+ const selectedText = getSelectedText ( )
168+
169+ const imageItems = Array . from ( e . clipboardData ?. items ?? [ ] ) . filter ( ( i ) => i . type . startsWith ( "image/" ) )
170+
171+ switch ( true ) {
172+ case Boolean ( selectedText ) && isHttpUrl ( pasteText ) : {
173+ e . preventDefault ( )
174+ replaceSelection ( `[${ selectedText } ](${ pasteText . trim ( ) } )` )
175+
176+ return
120177 }
121- }
122178
123- const allItems = e . clipboardData ?. items ;
124- const items = [ ...( allItems ?? [ ] ) ] . filter ( ( i ) => i . type . startsWith ( "image/" ) ) ;
125- if ( ! items . length ) return ;
126-
127- setUploading ( true ) ;
128-
129- for ( const item of items ) {
130- e . preventDefault ( ) ;
131- const file = item . getAsFile ( ) ;
132- if ( ! file ) continue ;
133-
134- imageCreate . mutate (
135- { name : getUuid ( ) , file } ,
136- {
137- onSuccess : ( image ) => {
138- const baseUrl = window . location . origin ;
139- const selectedText = getSelection ( ) || "pasted image" ;
140- const replacementText = `` ;
141- replaceSelection ( replacementText ) ;
142- } ,
143- onError : ( ) => toast . error ( "Failed" , { description : "Probably an unsupported file type" } ) ,
144- onSettled : ( ) => setUploading ( false ) ,
179+ case imageItems . length > 0 : {
180+ e . preventDefault ( )
181+ setUploading ( true )
182+
183+ for ( const item of imageItems ) {
184+ const file = item . getAsFile ( )
185+ if ( ! file ) continue
186+
187+ imageCreate . mutate (
188+ { name : getUuid ( ) , file } ,
189+ {
190+ onSuccess : ( image ) => {
191+ const baseUrl = window . location . origin
192+ const alt = getSelectedText ( ) || "pasted image"
193+ replaceSelection ( `` )
194+ } ,
195+ onError : ( ) => toast . error ( "Failed" , { description : "Probably an unsupported file type" } ) ,
196+ onSettled : ( ) => setUploading ( false ) ,
197+ }
198+ )
145199 }
146- ) ;
200+
201+ return
202+ }
203+
204+ default :
205+ return
147206 }
148- } ;
207+ }
208+
209+ container . addEventListener ( "paste" , handlePaste )
149210
150- container . addEventListener ( "paste" , handlePaste ) ;
151- return ( ) => container . removeEventListener ( "paste" , handlePaste ) ;
152- } , [ value , onChange ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
211+ return ( ) => container . removeEventListener ( "paste" , handlePaste )
212+ } , [ value , onChange , imageCreate ] )
153213
154- const toggleEmojiSelector = ( ) => setEmojiOpen ( ( v ) => ! v ) ;
214+ const emojiTheme = useMemo ( ( ) => ( theme === "dark" ? Theme . DARK : Theme . LIGHT ) , [ theme ] )
215+
216+ const toggleEmojiSelector = ( ) => setEmojiOpen ( ( v ) => ! v )
155217
156218 const handleEmojiClick = ( emojiData : EmojiClickData ) => {
157- replaceSelection ( emojiData . emoji ) ;
158- setEmojiOpen ( false ) ;
159- } ;
219+ replaceSelection ( emojiData . emoji )
220+ setEmojiOpen ( false )
221+ }
160222
161223 return (
162224 < LoadingOverlay loading = { uploading } >
@@ -165,8 +227,8 @@ export function MarkdownCombo({ value = "", onChange, ...props }: MDEditorProps)
165227 value = { value }
166228 height = { 600 }
167229 onChange = { ( v ) => {
168- onChange ?.( v ) ;
169- requestAnimationFrame ( updateSelection ) ;
230+ onChange ?.( v )
231+ requestAnimationFrame ( updateSelection )
170232 } }
171233 previewOptions = { {
172234 rehypePlugins : [ [ rehypeSanitize ] ] ,
@@ -179,8 +241,8 @@ export function MarkdownCombo({ value = "", onChange, ...props }: MDEditorProps)
179241 type = "button"
180242 className = "mr-1 inline-flex items-center"
181243 onMouseDown = { ( e ) => {
182- e . preventDefault ( ) ;
183- updateSelection ( ) ;
244+ e . preventDefault ( )
245+ updateSelection ( )
184246 } }
185247 onClick = { toggleEmojiSelector }
186248 aria-label = "Insert emoji"
@@ -195,15 +257,11 @@ export function MarkdownCombo({ value = "", onChange, ...props }: MDEditorProps)
195257
196258 { emojiOpen && (
197259 < div className = "absolute z-50 top-10 right-2" >
198- < EmojiPicker
199- onEmojiClick = { handleEmojiClick }
200- autoFocusSearch
201- theme = { theme === "dark" ? Theme . DARK : Theme . LIGHT }
202- />
260+ < EmojiPicker onEmojiClick = { handleEmojiClick } autoFocusSearch theme = { emojiTheme } />
203261 </ div >
204262 ) }
205263 </ div >
206264 </ LoadingOverlay >
207- ) ;
265+ )
208266}
209267
0 commit comments