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