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