]> Untitled Git - lemmy-ui.git/blob - src/shared/components/common/markdown-textarea.tsx
Merge branch 'main' into custom-emojis
[lemmy-ui.git] / src / shared / components / common / markdown-textarea.tsx
1 import autosize from "autosize";
2 import { Component, linkEvent } from "inferno";
3 import { Prompt } from "inferno-router";
4 import { Language } from "lemmy-js-client";
5 import { pictrsUri } from "../../env";
6 import { i18n } from "../../i18next";
7 import { UserService } from "../../services";
8 import {
9   customEmojisLookup,
10   isBrowser,
11   markdownFieldCharacterLimit,
12   markdownHelpUrl,
13   mdToHtml,
14   pictrsDeleteToast,
15   randomStr,
16   relTags,
17   setupTippy,
18   setupTribute,
19   toast,
20 } from "../../utils";
21 import { EmojiPicker } from "./emoji-picker";
22 import { Icon, Spinner } from "./icon";
23 import { LanguageSelect } from "./language-select";
24
25 interface MarkdownTextAreaProps {
26   initialContent?: string;
27   initialLanguageId?: number;
28   placeholder?: string;
29   buttonTitle?: string;
30   maxLength?: number;
31   replyType?: boolean;
32   focus?: boolean;
33   disabled?: boolean;
34   finished?: boolean;
35   showLanguage?: boolean;
36   hideNavigationWarnings?: boolean;
37   onContentChange?(val: string): any;
38   onReplyCancel?(): any;
39   onSubmit?(msg: { val?: string; formId: string; languageId?: number }): any;
40   allLanguages: Language[]; // TODO should probably be nullable
41   siteLanguages: number[]; // TODO same
42 }
43
44 interface MarkdownTextAreaState {
45   content?: string;
46   languageId?: number;
47   previewMode: boolean;
48   loading: boolean;
49   imageLoading: boolean;
50 }
51
52 export class MarkdownTextArea extends Component<
53   MarkdownTextAreaProps,
54   MarkdownTextAreaState
55 > {
56   private id = `comment-textarea-${randomStr()}`;
57   private formId = `comment-form-${randomStr()}`;
58   private tribute: any;
59   state: MarkdownTextAreaState = {
60     content: this.props.initialContent,
61     languageId: this.props.initialLanguageId,
62     previewMode: false,
63     loading: false,
64     imageLoading: false,
65   };
66
67   constructor(props: any, context: any) {
68     super(props, context);
69
70     this.handleLanguageChange = this.handleLanguageChange.bind(this);
71
72     if (isBrowser()) {
73       this.tribute = setupTribute();
74     }
75   }
76
77   componentDidMount() {
78     let textarea: any = document.getElementById(this.id);
79     if (textarea) {
80       autosize(textarea);
81       this.tribute.attach(textarea);
82       textarea.addEventListener("tribute-replaced", () => {
83         this.setState({ content: textarea.value });
84         autosize.update(textarea);
85       });
86
87       this.quoteInsert();
88
89       if (this.props.focus) {
90         textarea.focus();
91       }
92
93       // TODO this is slow for some reason
94       setupTippy();
95     }
96   }
97
98   componentDidUpdate() {
99     if (!this.props.hideNavigationWarnings && this.state.content) {
100       window.onbeforeunload = () => true;
101     } else {
102       window.onbeforeunload = null;
103     }
104   }
105
106   componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
107     if (nextProps.finished) {
108       this.setState({ previewMode: false, loading: false, content: undefined });
109       if (this.props.replyType) {
110         this.props.onReplyCancel?.();
111       }
112
113       let textarea: any = document.getElementById(this.id);
114       let form: any = document.getElementById(this.formId);
115       form.reset();
116       setTimeout(() => autosize.update(textarea), 10);
117     }
118   }
119
120   componentWillUnmount() {
121     window.onbeforeunload = null;
122   }
123
124   render() {
125     let languageId = this.state.languageId;
126
127     return (
128       <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
129         <Prompt
130           when={!this.props.hideNavigationWarnings && this.state.content}
131           message={i18n.t("block_leaving")}
132         />
133         <div className="form-group row">
134           <div className={`col-sm-12`}>
135             <textarea
136               id={this.id}
137               className={`form-control ${this.state.previewMode && "d-none"}`}
138               value={this.state.content}
139               onInput={linkEvent(this, this.handleContentChange)}
140               onPaste={linkEvent(this, this.handleImageUploadPaste)}
141               required
142               disabled={this.props.disabled}
143               rows={2}
144               maxLength={this.props.maxLength ?? markdownFieldCharacterLimit}
145               placeholder={this.props.placeholder}
146             />
147             {this.state.previewMode && this.state.content && (
148               <div
149                 className="card border-secondary card-body md-div"
150                 dangerouslySetInnerHTML={mdToHtml(this.state.content)}
151               />
152             )}
153           </div>
154           <label className="sr-only" htmlFor={this.id}>
155             {i18n.t("body")}
156           </label>
157         </div>
158         <div className="row">
159           <div className="col-sm-12 d-flex flex-wrap">
160             {this.props.buttonTitle && (
161               <button
162                 type="submit"
163                 className="btn btn-sm btn-secondary mr-2"
164                 disabled={this.props.disabled || this.state.loading}
165               >
166                 {this.state.loading ? (
167                   <Spinner />
168                 ) : (
169                   <span>{this.props.buttonTitle}</span>
170                 )}
171               </button>
172             )}
173             {this.props.replyType && (
174               <button
175                 type="button"
176                 className="btn btn-sm btn-secondary mr-2"
177                 onClick={linkEvent(this, this.handleReplyCancel)}
178               >
179                 {i18n.t("cancel")}
180               </button>
181             )}
182             {this.state.content && (
183               <button
184                 className={`btn btn-sm btn-secondary mr-2 ${
185                   this.state.previewMode && "active"
186                 }`}
187                 onClick={linkEvent(this, this.handlePreviewToggle)}
188               >
189                 {i18n.t("preview")}
190               </button>
191             )}
192             {/* A flex expander */}
193             <div className="flex-grow-1"></div>
194
195             {this.props.showLanguage && (
196               <LanguageSelect
197                 iconVersion
198                 allLanguages={this.props.allLanguages}
199                 selectedLanguageIds={
200                   languageId ? Array.of(languageId) : undefined
201                 }
202                 siteLanguages={this.props.siteLanguages}
203                 multiple={false}
204                 onChange={this.handleLanguageChange}
205               />
206             )}
207             <button
208               className="btn btn-sm text-muted"
209               data-tippy-content={i18n.t("bold")}
210               aria-label={i18n.t("bold")}
211               onClick={linkEvent(this, this.handleInsertBold)}
212             >
213               <Icon icon="bold" classes="icon-inline" />
214             </button>
215             <button
216               className="btn btn-sm text-muted"
217               data-tippy-content={i18n.t("italic")}
218               aria-label={i18n.t("italic")}
219               onClick={linkEvent(this, this.handleInsertItalic)}
220             >
221               <Icon icon="italic" classes="icon-inline" />
222             </button>
223             <button
224               className="btn btn-sm text-muted"
225               data-tippy-content={i18n.t("link")}
226               aria-label={i18n.t("link")}
227               onClick={linkEvent(this, this.handleInsertLink)}
228             >
229               <Icon icon="link" classes="icon-inline" />
230             </button>
231             <EmojiPicker onEmojiClick={(e) => this.handleEmoji(this,e)}></EmojiPicker>
232             <form className="btn btn-sm text-muted font-weight-bold">
233               <label
234                 htmlFor={`file-upload-${this.id}`}
235                 className={`mb-0 ${
236                   UserService.Instance.myUserInfo && "pointer"
237                 }`}
238                 data-tippy-content={i18n.t("upload_image")}
239               >
240                 {this.state.imageLoading ? (
241                   <Spinner />
242                 ) : (
243                   <Icon icon="image" classes="icon-inline" />
244                 )}
245               </label>
246               <input
247                 id={`file-upload-${this.id}`}
248                 type="file"
249                 accept="image/*,video/*"
250                 name="file"
251                 className="d-none"
252                 disabled={!UserService.Instance.myUserInfo}
253                 onChange={linkEvent(this, this.handleImageUpload)}
254               />
255             </form>
256             <button
257               className="btn btn-sm text-muted"
258               data-tippy-content={i18n.t("header")}
259               aria-label={i18n.t("header")}
260               onClick={linkEvent(this, this.handleInsertHeader)}
261             >
262               <Icon icon="header" classes="icon-inline" />
263             </button>
264             <button
265               className="btn btn-sm text-muted"
266               data-tippy-content={i18n.t("strikethrough")}
267               aria-label={i18n.t("strikethrough")}
268               onClick={linkEvent(this, this.handleInsertStrikethrough)}
269             >
270               <Icon icon="strikethrough" classes="icon-inline" />
271             </button>
272             <button
273               className="btn btn-sm text-muted"
274               data-tippy-content={i18n.t("quote")}
275               aria-label={i18n.t("quote")}
276               onClick={linkEvent(this, this.handleInsertQuote)}
277             >
278               <Icon icon="format_quote" classes="icon-inline" />
279             </button>
280             <button
281               className="btn btn-sm text-muted"
282               data-tippy-content={i18n.t("list")}
283               aria-label={i18n.t("list")}
284               onClick={linkEvent(this, this.handleInsertList)}
285             >
286               <Icon icon="list" classes="icon-inline" />
287             </button>
288             <button
289               className="btn btn-sm text-muted"
290               data-tippy-content={i18n.t("code")}
291               aria-label={i18n.t("code")}
292               onClick={linkEvent(this, this.handleInsertCode)}
293             >
294               <Icon icon="code" classes="icon-inline" />
295             </button>
296             <button
297               className="btn btn-sm text-muted"
298               data-tippy-content={i18n.t("subscript")}
299               aria-label={i18n.t("subscript")}
300               onClick={linkEvent(this, this.handleInsertSubscript)}
301             >
302               <Icon icon="subscript" classes="icon-inline" />
303             </button>
304             <button
305               className="btn btn-sm text-muted"
306               data-tippy-content={i18n.t("superscript")}
307               aria-label={i18n.t("superscript")}
308               onClick={linkEvent(this, this.handleInsertSuperscript)}
309             >
310               <Icon icon="superscript" classes="icon-inline" />
311             </button>
312             <button
313               className="btn btn-sm text-muted"
314               data-tippy-content={i18n.t("spoiler")}
315               aria-label={i18n.t("spoiler")}
316               onClick={linkEvent(this, this.handleInsertSpoiler)}
317             >
318               <Icon icon="alert-triangle" classes="icon-inline" />
319             </button>
320             <a
321               href={markdownHelpUrl}
322               className="btn btn-sm text-muted font-weight-bold"
323               title={i18n.t("formatting_help")}
324               rel={relTags}
325             >
326               <Icon icon="help-circle" classes="icon-inline" />
327             </a>
328           </div>
329         </div>
330       </form>
331     );
332   }
333
334   handleEmoji(i: MarkdownTextArea, e: any) {
335     let value = e.native;
336     if (value == null){
337       let emoji = customEmojisLookup.get(e.id)?.custom_emoji;
338       if (emoji){
339         value = `![${emoji.alt_text}](${emoji.image_url} "${emoji.shortcode}")`;
340       }
341     }
342     i.setState({
343       content: `${i.state.content ?? ""} ${value} `,
344     });
345     i.contentChange();
346     let textarea: any = document.getElementById(i.id);
347     autosize.update(textarea);
348   }
349
350   handleImageUploadPaste(i: MarkdownTextArea, event: any) {
351     let image = event.clipboardData.files[0];
352     if (image) {
353       i.handleImageUpload(i, image);
354     }
355   }
356
357   handleImageUpload(i: MarkdownTextArea, event: any) {
358     let file: any;
359     if (event.target) {
360       event.preventDefault();
361       file = event.target.files[0];
362     } else {
363       file = event;
364     }
365
366     const formData = new FormData();
367     formData.append("images[]", file);
368
369     i.setState({ imageLoading: true });
370
371     fetch(pictrsUri, {
372       method: "POST",
373       body: formData,
374     })
375       .then(res => res.json())
376       .then(res => {
377         console.log("pictrs upload:");
378         console.log(res);
379         if (res.msg == "ok") {
380           let hash = res.files[0].file;
381           let url = `${pictrsUri}/${hash}`;
382           let deleteToken = res.files[0].delete_token;
383           let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
384           let imageMarkdown = `![](${url})`;
385           let content = i.state.content;
386           i.setState({
387             content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
388             imageLoading: false,
389           });
390           i.contentChange();
391           let textarea: any = document.getElementById(i.id);
392           autosize.update(textarea);
393           pictrsDeleteToast(
394             `${i18n.t("click_to_delete_picture")}: ${file.name}`,
395             `${i18n.t("picture_deleted")}: ${file.name}`,
396             `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
397             deleteUrl
398           );
399         } else {
400           i.setState({ imageLoading: false });
401           toast(JSON.stringify(res), "danger");
402         }
403       })
404       .catch(error => {
405         i.setState({ imageLoading: false });
406         console.error(error);
407         toast(error, "danger");
408       });
409   }
410
411   contentChange() {
412     // Coerces the undefineds to empty strings, for replacing in the DB
413     let content = this.state.content ?? "";
414     this.props.onContentChange?.(content);
415   }
416
417   handleContentChange(i: MarkdownTextArea, event: any) {
418     i.setState({ content: event.target.value });
419     i.contentChange();
420   }
421
422   handlePreviewToggle(i: MarkdownTextArea, event: any) {
423     event.preventDefault();
424     i.setState({ previewMode: !i.state.previewMode });
425   }
426
427   handleLanguageChange(val: number[]) {
428     this.setState({ languageId: val[0] });
429   }
430
431   handleSubmit(i: MarkdownTextArea, event: any) {
432     event.preventDefault();
433     i.setState({ loading: true });
434     let msg = {
435       val: i.state.content,
436       formId: i.formId,
437       languageId: i.state.languageId,
438     };
439     i.props.onSubmit?.(msg);
440   }
441
442   handleReplyCancel(i: MarkdownTextArea) {
443     i.props.onReplyCancel?.();
444   }
445
446   handleInsertLink(i: MarkdownTextArea, event: any) {
447     event.preventDefault();
448
449     let textarea: any = document.getElementById(i.id);
450     let start: number = textarea.selectionStart;
451     let end: number = textarea.selectionEnd;
452
453     let content = i.state.content;
454
455     if (!i.state.content) {
456       i.setState({ content: "" });
457     }
458
459     if (start !== end) {
460       let selectedText = content?.substring(start, end);
461       i.setState({
462         content: `${content?.substring(
463           0,
464           start
465         )}[${selectedText}]()${content?.substring(end)}`,
466       });
467       textarea.focus();
468       setTimeout(() => (textarea.selectionEnd = end + 3), 10);
469     } else {
470       i.setState({ content: `${content} []()` });
471       textarea.focus();
472       setTimeout(() => (textarea.selectionEnd -= 1), 10);
473     }
474     i.contentChange();
475   }
476
477   simpleSurround(chars: string) {
478     this.simpleSurroundBeforeAfter(chars, chars);
479   }
480
481   simpleBeginningofLine(chars: string) {
482     this.simpleSurroundBeforeAfter(`${chars}`, "", "");
483   }
484
485   simpleSurroundBeforeAfter(
486     beforeChars: string,
487     afterChars: string,
488     emptyChars = "___"
489   ) {
490     let content = this.state.content;
491     if (!this.state.content) {
492       this.setState({ content: "" });
493     }
494     let textarea: any = document.getElementById(this.id);
495     let start: number = textarea.selectionStart;
496     let end: number = textarea.selectionEnd;
497
498     if (start !== end) {
499       let selectedText = content?.substring(start, end);
500       this.setState({
501         content: `${content?.substring(
502           0,
503           start
504         )}${beforeChars}${selectedText}${afterChars}${content?.substring(end)}`,
505       });
506     } else {
507       this.setState({
508         content: `${content}${beforeChars}${emptyChars}${afterChars}`,
509       });
510     }
511     this.contentChange();
512
513     textarea.focus();
514
515     if (start !== end) {
516       textarea.setSelectionRange(
517         start + beforeChars.length,
518         end + afterChars.length
519       );
520     } else {
521       textarea.setSelectionRange(
522         start + beforeChars.length,
523         end + emptyChars.length + afterChars.length
524       );
525     }
526
527     setTimeout(() => {
528       autosize.update(textarea);
529     }, 10);
530   }
531
532   handleInsertBold(i: MarkdownTextArea, event: any) {
533     event.preventDefault();
534     i.simpleSurround("**");
535   }
536
537   handleInsertItalic(i: MarkdownTextArea, event: any) {
538     event.preventDefault();
539     i.simpleSurround("*");
540   }
541
542   handleInsertCode(i: MarkdownTextArea, event: any) {
543     event.preventDefault();
544     if (i.getSelectedText().split(/\r*\n/).length > 1) {
545       i.simpleSurroundBeforeAfter("```\n", "\n```");
546     } else {
547       i.simpleSurround("`");
548     }
549   }
550
551   handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
552     event.preventDefault();
553     i.simpleSurround("~~");
554   }
555
556   handleInsertList(i: MarkdownTextArea, event: any) {
557     event.preventDefault();
558     i.simpleBeginningofLine("-");
559   }
560
561   handleInsertQuote(i: MarkdownTextArea, event: any) {
562     event.preventDefault();
563     i.simpleBeginningofLine(">");
564   }
565
566   handleInsertHeader(i: MarkdownTextArea, event: any) {
567     event.preventDefault();
568     i.simpleBeginningofLine("#");
569   }
570
571   handleInsertSubscript(i: MarkdownTextArea, event: any) {
572     event.preventDefault();
573     i.simpleSurround("~");
574   }
575
576   handleInsertSuperscript(i: MarkdownTextArea, event: any) {
577     event.preventDefault();
578     i.simpleSurround("^");
579   }
580
581   simpleInsert(chars: string) {
582     let content = this.state.content;
583     if (!content) {
584       this.setState({ content: `${chars} ` });
585     } else {
586       this.setState({
587         content: `${content}\n${chars} `,
588       });
589     }
590
591     let textarea: any = document.getElementById(this.id);
592     textarea.focus();
593     setTimeout(() => {
594       autosize.update(textarea);
595     }, 10);
596     this.contentChange();
597   }
598
599   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
600     event.preventDefault();
601     let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
602     let afterChars = "\n:::\n";
603     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
604   }
605
606   quoteInsert() {
607     let textarea: any = document.getElementById(this.id);
608     let selectedText = window.getSelection()?.toString();
609     let content = this.state.content;
610     if (selectedText) {
611       let quotedText =
612         selectedText
613           .split("\n")
614           .map(t => `> ${t}`)
615           .join("\n") + "\n\n";
616       if (!content) {
617         this.setState({ content: "" });
618       } else {
619         this.setState({ content: `${content}\n` });
620       }
621       this.setState({
622         content: `${content}${quotedText}`,
623       });
624       this.contentChange();
625       // Not sure why this needs a delay
626       setTimeout(() => autosize.update(textarea), 10);
627     }
628   }
629
630   getSelectedText(): string {
631     let textarea: any = document.getElementById(this.id);
632     let start: number = textarea.selectionStart;
633     let end: number = textarea.selectionEnd;
634     return start !== end ? this.state.content?.substring(start, end) ?? "" : "";
635   }
636 }