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