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