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