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