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