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