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