]> Untitled Git - lemmy-ui.git/blob - src/shared/components/markdown-textarea.tsx
Don't restore scroll position on page refresh. Fixes #186
[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   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.localUserView && "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.localUserView}
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     i.simpleSurround("`");
479   }
480
481   handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
482     event.preventDefault();
483     i.simpleSurround("~~");
484   }
485
486   handleInsertList(i: MarkdownTextArea, event: any) {
487     event.preventDefault();
488     i.simpleBeginningofLine("-");
489   }
490
491   handleInsertQuote(i: MarkdownTextArea, event: any) {
492     event.preventDefault();
493     i.simpleBeginningofLine(">");
494   }
495
496   handleInsertHeader(i: MarkdownTextArea, event: any) {
497     event.preventDefault();
498     i.simpleBeginningofLine("#");
499   }
500
501   handleInsertSubscript(i: MarkdownTextArea, event: any) {
502     event.preventDefault();
503     i.simpleSurround("~");
504   }
505
506   handleInsertSuperscript(i: MarkdownTextArea, event: any) {
507     event.preventDefault();
508     i.simpleSurround("^");
509   }
510
511   simpleInsert(chars: string) {
512     if (!this.state.content) {
513       this.state.content = `${chars} `;
514     } else {
515       this.state.content += `\n${chars} `;
516     }
517
518     let textarea: any = document.getElementById(this.id);
519     textarea.focus();
520     setTimeout(() => {
521       autosize.update(textarea);
522     }, 10);
523     this.contentChange();
524     this.setState(this.state);
525   }
526
527   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
528     event.preventDefault();
529     let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
530     let afterChars = "\n:::\n";
531     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
532   }
533
534   quoteInsert() {
535     let textarea: any = document.getElementById(this.id);
536     let selectedText = window.getSelection().toString();
537     if (selectedText) {
538       let quotedText =
539         selectedText
540           .split("\n")
541           .map(t => `> ${t}`)
542           .join("\n") + "\n\n";
543       if (this.state.content == null) {
544         this.state.content = "";
545       } else {
546         this.state.content += "\n";
547       }
548       this.state.content += quotedText;
549       this.contentChange();
550       this.setState(this.state);
551       // Not sure why this needs a delay
552       setTimeout(() => autosize.update(textarea), 10);
553     }
554   }
555 }