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