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