]> Untitled Git - lemmy-ui.git/blob - src/shared/components/common/markdown-textarea.tsx
Adding option types 2 (#689)
[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"),
371             i18n.t("picture_deleted"),
372             deleteUrl
373           );
374         } else {
375           i.state.imageLoading = false;
376           i.setState(i.state);
377           toast(JSON.stringify(res), "danger");
378         }
379       })
380       .catch(error => {
381         i.state.imageLoading = false;
382         i.setState(i.state);
383         console.error(error);
384         toast(error, "danger");
385       });
386   }
387
388   contentChange() {
389     if (this.props.onContentChange) {
390       this.props.onContentChange(toUndefined(this.state.content));
391     }
392   }
393
394   handleContentChange(i: MarkdownTextArea, event: any) {
395     i.state.content = Some(event.target.value);
396     i.contentChange();
397     i.setState(i.state);
398   }
399
400   handlePreviewToggle(i: MarkdownTextArea, event: any) {
401     event.preventDefault();
402     i.state.previewMode = !i.state.previewMode;
403     i.setState(i.state);
404   }
405
406   handleSubmit(i: MarkdownTextArea, event: any) {
407     event.preventDefault();
408     i.state.loading = true;
409     i.setState(i.state);
410     let msg = { val: toUndefined(i.state.content), formId: i.formId };
411     i.props.onSubmit(msg);
412   }
413
414   handleReplyCancel(i: MarkdownTextArea) {
415     i.props.onReplyCancel();
416   }
417
418   handleInsertLink(i: MarkdownTextArea, event: any) {
419     event.preventDefault();
420
421     let textarea: any = document.getElementById(i.id);
422     let start: number = textarea.selectionStart;
423     let end: number = textarea.selectionEnd;
424
425     if (i.state.content.isNone()) {
426       i.state.content = Some("");
427     }
428
429     let content = i.state.content.unwrap();
430
431     if (start !== end) {
432       let selectedText = content.substring(start, end);
433       i.state.content = Some(
434         `${content.substring(0, start)}[${selectedText}]()${content.substring(
435           end
436         )}`
437       );
438       textarea.focus();
439       setTimeout(() => (textarea.selectionEnd = end + 3), 10);
440     } else {
441       i.state.content = Some(`${content} []()`);
442       textarea.focus();
443       setTimeout(() => (textarea.selectionEnd -= 1), 10);
444     }
445     i.contentChange();
446     i.setState(i.state);
447   }
448
449   simpleSurround(chars: string) {
450     this.simpleSurroundBeforeAfter(chars, chars);
451   }
452
453   simpleBeginningofLine(chars: string) {
454     this.simpleSurroundBeforeAfter(`${chars}`, "", "");
455   }
456
457   simpleSurroundBeforeAfter(
458     beforeChars: string,
459     afterChars: string,
460     emptyChars = "___"
461   ) {
462     if (this.state.content.isNone()) {
463       this.state.content = Some("");
464     }
465     let textarea: any = document.getElementById(this.id);
466     let start: number = textarea.selectionStart;
467     let end: number = textarea.selectionEnd;
468
469     let content = this.state.content.unwrap();
470
471     if (start !== end) {
472       let selectedText = content.substring(start, end);
473       this.state.content = Some(
474         `${content.substring(
475           0,
476           start
477         )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
478       );
479     } else {
480       this.state.content = Some(
481         `${content}${beforeChars}${emptyChars}${afterChars}`
482       );
483     }
484     this.contentChange();
485     this.setState(this.state);
486
487     textarea.focus();
488
489     if (start !== end) {
490       textarea.setSelectionRange(
491         start + beforeChars.length,
492         end + afterChars.length
493       );
494     } else {
495       textarea.setSelectionRange(
496         start + beforeChars.length,
497         end + emptyChars.length + afterChars.length
498       );
499     }
500
501     setTimeout(() => {
502       autosize.update(textarea);
503     }, 10);
504   }
505
506   handleInsertBold(i: MarkdownTextArea, event: any) {
507     event.preventDefault();
508     i.simpleSurround("**");
509   }
510
511   handleInsertItalic(i: MarkdownTextArea, event: any) {
512     event.preventDefault();
513     i.simpleSurround("*");
514   }
515
516   handleInsertCode(i: MarkdownTextArea, event: any) {
517     event.preventDefault();
518     if (i.getSelectedText().split(/\r*\n/).length > 1) {
519       i.simpleSurroundBeforeAfter("```\n", "\n```");
520     } else {
521       i.simpleSurround("`");
522     }
523   }
524
525   handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
526     event.preventDefault();
527     i.simpleSurround("~~");
528   }
529
530   handleInsertList(i: MarkdownTextArea, event: any) {
531     event.preventDefault();
532     i.simpleBeginningofLine("-");
533   }
534
535   handleInsertQuote(i: MarkdownTextArea, event: any) {
536     event.preventDefault();
537     i.simpleBeginningofLine(">");
538   }
539
540   handleInsertHeader(i: MarkdownTextArea, event: any) {
541     event.preventDefault();
542     i.simpleBeginningofLine("#");
543   }
544
545   handleInsertSubscript(i: MarkdownTextArea, event: any) {
546     event.preventDefault();
547     i.simpleSurround("~");
548   }
549
550   handleInsertSuperscript(i: MarkdownTextArea, event: any) {
551     event.preventDefault();
552     i.simpleSurround("^");
553   }
554
555   simpleInsert(chars: string) {
556     if (this.state.content.isNone()) {
557       this.state.content = Some(`${chars} `);
558     } else {
559       this.state.content = Some(`${this.state.content.unwrap()}\n${chars} `);
560     }
561
562     let textarea: any = document.getElementById(this.id);
563     textarea.focus();
564     setTimeout(() => {
565       autosize.update(textarea);
566     }, 10);
567     this.contentChange();
568     this.setState(this.state);
569   }
570
571   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
572     event.preventDefault();
573     let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
574     let afterChars = "\n:::\n";
575     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
576   }
577
578   quoteInsert() {
579     let textarea: any = document.getElementById(this.id);
580     let selectedText = window.getSelection().toString();
581     if (selectedText) {
582       let quotedText =
583         selectedText
584           .split("\n")
585           .map(t => `> ${t}`)
586           .join("\n") + "\n\n";
587       if (this.state.content.isNone()) {
588         this.state.content = Some("");
589       } else {
590         this.state.content = Some(`${this.state.content.unwrap()}\n`);
591       }
592       this.state.content = Some(`${this.state.content.unwrap()}${quotedText}`);
593       this.contentChange();
594       this.setState(this.state);
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
605       ? this.state.content.unwrap().substring(start, end)
606       : "";
607   }
608 }