]> Untitled Git - lemmy-ui.git/blob - src/shared/components/common/markdown-textarea.tsx
Fixing nav-link
[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         toast(error, "danger");
369       });
370   }
371
372   contentChange() {
373     if (this.props.onContentChange) {
374       this.props.onContentChange(this.state.content);
375     }
376   }
377
378   handleContentChange(i: MarkdownTextArea, event: any) {
379     i.state.content = event.target.value;
380     i.contentChange();
381     i.setState(i.state);
382   }
383
384   handlePreviewToggle(i: MarkdownTextArea, event: any) {
385     event.preventDefault();
386     i.state.previewMode = !i.state.previewMode;
387     i.setState(i.state);
388   }
389
390   handleSubmit(i: MarkdownTextArea, event: any) {
391     event.preventDefault();
392     i.state.loading = true;
393     i.setState(i.state);
394     let msg = { val: i.state.content, formId: i.formId };
395     i.props.onSubmit(msg);
396   }
397
398   handleReplyCancel(i: MarkdownTextArea) {
399     i.props.onReplyCancel();
400   }
401
402   handleInsertLink(i: MarkdownTextArea, event: any) {
403     event.preventDefault();
404     if (!i.state.content) {
405       i.state.content = "";
406     }
407     let textarea: any = document.getElementById(i.id);
408     let start: number = textarea.selectionStart;
409     let end: number = textarea.selectionEnd;
410
411     if (start !== end) {
412       let selectedText = i.state.content.substring(start, end);
413       i.state.content = `${i.state.content.substring(
414         0,
415         start
416       )}[${selectedText}]()${i.state.content.substring(end)}`;
417       textarea.focus();
418       setTimeout(() => (textarea.selectionEnd = end + 3), 10);
419     } else {
420       i.state.content += "[]()";
421       textarea.focus();
422       setTimeout(() => (textarea.selectionEnd -= 1), 10);
423     }
424     i.contentChange();
425     i.setState(i.state);
426   }
427
428   simpleSurround(chars: string) {
429     this.simpleSurroundBeforeAfter(chars, chars);
430   }
431
432   simpleBeginningofLine(chars: string) {
433     this.simpleSurroundBeforeAfter(`${chars} `, "", "");
434   }
435
436   simpleSurroundBeforeAfter(
437     beforeChars: string,
438     afterChars: string,
439     emptyChars = "___"
440   ) {
441     if (!this.state.content) {
442       this.state.content = "";
443     }
444     let textarea: any = document.getElementById(this.id);
445     let start: number = textarea.selectionStart;
446     let end: number = textarea.selectionEnd;
447
448     if (start !== end) {
449       let selectedText = this.state.content.substring(start, end);
450       this.state.content = `${this.state.content.substring(
451         0,
452         start
453       )}${beforeChars}${selectedText}${afterChars}${this.state.content.substring(
454         end
455       )}`;
456     } else {
457       this.state.content += `${beforeChars}${emptyChars}${afterChars}`;
458     }
459     this.contentChange();
460     this.setState(this.state);
461     setTimeout(() => {
462       autosize.update(textarea);
463     }, 10);
464   }
465
466   handleInsertBold(i: MarkdownTextArea, event: any) {
467     event.preventDefault();
468     i.simpleSurround("**");
469   }
470
471   handleInsertItalic(i: MarkdownTextArea, event: any) {
472     event.preventDefault();
473     i.simpleSurround("*");
474   }
475
476   handleInsertCode(i: MarkdownTextArea, event: any) {
477     event.preventDefault();
478     if (i.getSelectedText().split(/\r*\n/).length > 1) {
479       i.simpleSurroundBeforeAfter("```\n", "\n```");
480     } else {
481       i.simpleSurround("`");
482     }
483   }
484
485   handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
486     event.preventDefault();
487     i.simpleSurround("~~");
488   }
489
490   handleInsertList(i: MarkdownTextArea, event: any) {
491     event.preventDefault();
492     i.simpleBeginningofLine("-");
493   }
494
495   handleInsertQuote(i: MarkdownTextArea, event: any) {
496     event.preventDefault();
497     i.simpleBeginningofLine(">");
498   }
499
500   handleInsertHeader(i: MarkdownTextArea, event: any) {
501     event.preventDefault();
502     i.simpleBeginningofLine("#");
503   }
504
505   handleInsertSubscript(i: MarkdownTextArea, event: any) {
506     event.preventDefault();
507     i.simpleSurround("~");
508   }
509
510   handleInsertSuperscript(i: MarkdownTextArea, event: any) {
511     event.preventDefault();
512     i.simpleSurround("^");
513   }
514
515   simpleInsert(chars: string) {
516     if (!this.state.content) {
517       this.state.content = `${chars} `;
518     } else {
519       this.state.content += `\n${chars} `;
520     }
521
522     let textarea: any = document.getElementById(this.id);
523     textarea.focus();
524     setTimeout(() => {
525       autosize.update(textarea);
526     }, 10);
527     this.contentChange();
528     this.setState(this.state);
529   }
530
531   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
532     event.preventDefault();
533     let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
534     let afterChars = "\n:::\n";
535     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
536   }
537
538   quoteInsert() {
539     let textarea: any = document.getElementById(this.id);
540     let selectedText = window.getSelection().toString();
541     if (selectedText) {
542       let quotedText =
543         selectedText
544           .split("\n")
545           .map(t => `> ${t}`)
546           .join("\n") + "\n\n";
547       if (this.state.content == null) {
548         this.state.content = "";
549       } else {
550         this.state.content += "\n";
551       }
552       this.state.content += quotedText;
553       this.contentChange();
554       this.setState(this.state);
555       // Not sure why this needs a delay
556       setTimeout(() => autosize.update(textarea), 10);
557     }
558   }
559
560   getSelectedText(): string {
561     let textarea: any = document.getElementById(this.id);
562     let start: number = textarea.selectionStart;
563     let end: number = textarea.selectionEnd;
564     return start !== end ? this.state.content.substring(start, end) : "";
565   }
566 }