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