]> Untitled Git - lemmy-ui.git/blobdiff - src/shared/components/common/markdown-textarea.tsx
Merge branch 'main' into feat/markdown-format-bar-above
[lemmy-ui.git] / src / shared / components / common / markdown-textarea.tsx
index 1bb52a36de9939e711ba41e744236eb7dd935c7b..ef7ba0187df3d4a7e9c0916eb0c5fd5df22672c6 100644 (file)
@@ -1,15 +1,19 @@
-import { None, Option, Some } from "@sniptt/monads";
 import autosize from "autosize";
+import classNames from "classnames";
+import { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
-import { Prompt } from "inferno-router";
-import { toUndefined } from "lemmy-js-client";
-import { pictrsUri } from "../../env";
+import { Language } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { UserService } from "../../services";
+import { HttpService, UserService } from "../../services";
 import {
+  concurrentImageUpload,
+  customEmojisLookup,
   isBrowser,
+  markdownFieldCharacterLimit,
   markdownHelpUrl,
+  maxUploadImages,
   mdToHtml,
+  numToSI,
   pictrsDeleteToast,
   randomStr,
   relTags,
@@ -17,61 +21,79 @@ import {
   setupTribute,
   toast,
 } from "../../utils";
+import { EmojiPicker } from "./emoji-picker";
 import { Icon, Spinner } from "./icon";
+import { LanguageSelect } from "./language-select";
+import NavigationPrompt from "./navigation-prompt";
+import ProgressBar from "./progress-bar";
 
 interface MarkdownTextAreaProps {
-  initialContent: Option<string>;
-  placeholder: Option<string>;
-  buttonTitle: Option<string>;
-  maxLength: Option<number>;
+  initialContent?: string;
+  initialLanguageId?: number;
+  placeholder?: string;
+  buttonTitle?: string;
+  maxLength?: number;
   replyType?: boolean;
   focus?: boolean;
   disabled?: boolean;
   finished?: boolean;
+  showLanguage?: boolean;
   hideNavigationWarnings?: boolean;
-  onContentChange?(val: string): any;
-  onReplyCancel?(): any;
-  onSubmit?(msg: { val: string; formId: string }): any;
+  onContentChange?(val: string): void;
+  onReplyCancel?(): void;
+  onSubmit?(content: string, formId: string, languageId?: number): void;
+  allLanguages: Language[]; // TODO should probably be nullable
+  siteLanguages: number[]; // TODO same
+}
+
+interface ImageUploadStatus {
+  total: number;
+  uploaded: number;
 }
 
 interface MarkdownTextAreaState {
-  content: Option<string>;
+  content?: string;
+  languageId?: number;
   previewMode: boolean;
+  imageUploadStatus?: ImageUploadStatus;
   loading: boolean;
-  imageLoading: boolean;
+  submitted: boolean;
 }
 
 export class MarkdownTextArea extends Component<
   MarkdownTextAreaProps,
   MarkdownTextAreaState
 > {
-  private id = `comment-textarea-${randomStr()}`;
-  private formId = `comment-form-${randomStr()}`;
+  private id = `markdown-textarea-${randomStr()}`;
+  private formId = `markdown-form-${randomStr()}`;
+
   private tribute: any;
-  private emptyState: MarkdownTextAreaState = {
+
+  state: MarkdownTextAreaState = {
     content: this.props.initialContent,
+    languageId: this.props.initialLanguageId,
     previewMode: false,
     loading: false,
-    imageLoading: false,
+    submitted: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
+    this.handleLanguageChange = this.handleLanguageChange.bind(this);
+
     if (isBrowser()) {
       this.tribute = setupTribute();
     }
-    this.state = this.emptyState;
   }
 
   componentDidMount() {
-    let textarea: any = document.getElementById(this.id);
+    const textarea: any = document.getElementById(this.id);
     if (textarea) {
       autosize(textarea);
       this.tribute.attach(textarea);
       textarea.addEventListener("tribute-replaced", () => {
-        this.state.content = Some(textarea.value);
-        this.setState(this.state);
+        this.setState({ content: textarea.value });
         autosize.update(textarea);
       });
 
@@ -86,365 +108,456 @@ export class MarkdownTextArea extends Component<
     }
   }
 
-  componentDidUpdate() {
-    if (!this.props.hideNavigationWarnings && this.state.content.isSome()) {
-      window.onbeforeunload = () => true;
-    } else {
-      window.onbeforeunload = undefined;
-    }
-  }
-
   componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
     if (nextProps.finished) {
-      this.state.previewMode = false;
-      this.state.loading = false;
-      this.state.content = None;
-      this.setState(this.state);
+      this.setState({
+        previewMode: false,
+        imageUploadStatus: undefined,
+        loading: false,
+        content: undefined,
+      });
       if (this.props.replyType) {
-        this.props.onReplyCancel();
+        this.props.onReplyCancel?.();
       }
 
-      let textarea: any = document.getElementById(this.id);
-      let form: any = document.getElementById(this.formId);
+      const textarea: any = document.getElementById(this.id);
+      const form: any = document.getElementById(this.formId);
       form.reset();
       setTimeout(() => autosize.update(textarea), 10);
-      this.setState(this.state);
     }
   }
 
-  componentWillUnmount() {
-    window.onbeforeunload = null;
-  }
-
   render() {
+    const languageId = this.state.languageId;
+
+    // TODO add these prompts back in at some point
+    // <Prompt
+    //   when={!this.props.hideNavigationWarnings && this.state.content}
+    //   message={i18n.t("block_leaving")}
+    // />
     return (
       <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
-        <Prompt
+        <NavigationPrompt
           when={
-            !this.props.hideNavigationWarnings && this.state.content.isSome()
+            !this.props.hideNavigationWarnings &&
+            !!this.state.content &&
+            !this.state.submitted
           }
-          message={i18n.t("block_leaving")}
         />
-        <div class="form-group row">
-          <div className={`col-sm-12`}>
-            <textarea
-              id={this.id}
-              className={`form-control ${this.state.previewMode && "d-none"}`}
-              value={toUndefined(this.state.content)}
-              onInput={linkEvent(this, this.handleContentChange)}
-              onPaste={linkEvent(this, this.handleImageUploadPaste)}
-              required
-              disabled={this.props.disabled}
-              rows={2}
-              maxLength={this.props.maxLength.unwrapOr(10000)}
-              placeholder={toUndefined(this.props.placeholder)}
-            />
-            {this.state.previewMode &&
-              this.state.content.match({
-                some: content => (
+        <div className="form-group row">
+          <div className="col-12">
+            <div
+              className="rounded bg-light overflow-hidden"
+              style={{
+                border: "1px solid var(--medium-light)",
+              }}
+            >
+              <div
+                className="d-flex flex-wrap"
+                style={{
+                  "border-bottom": "1px solid var(--medium-light)",
+                }}
+              >
+                {this.getFormatButton("bold", this.handleInsertBold)}
+                {this.getFormatButton("italic", this.handleInsertItalic)}
+                {this.getFormatButton("link", this.handleInsertLink)}
+                <EmojiPicker
+                  onEmojiClick={e => this.handleEmoji(this, e)}
+                  disabled={this.isDisabled}
+                ></EmojiPicker>
+                <form className="btn btn-sm text-muted font-weight-bold">
+                  <label
+                    htmlFor={`file-upload-${this.id}`}
+                    className={`mb-0 ${
+                      UserService.Instance.myUserInfo && "pointer"
+                    }`}
+                    data-tippy-content={i18n.t("upload_image")}
+                  >
+                    {this.state.imageUploadStatus ? (
+                      <Spinner />
+                    ) : (
+                      <Icon icon="image" classes="icon-inline" />
+                    )}
+                  </label>
+                  <input
+                    id={`file-upload-${this.id}`}
+                    type="file"
+                    accept="image/*,video/*"
+                    name="file"
+                    className="d-none"
+                    multiple
+                    disabled={
+                      !UserService.Instance.myUserInfo || this.isDisabled
+                    }
+                    onChange={linkEvent(this, this.handleImageUpload)}
+                  />
+                </form>
+                {this.getFormatButton("header", this.handleInsertHeader)}
+                {this.getFormatButton(
+                  "strikethrough",
+                  this.handleInsertStrikethrough
+                )}
+                {this.getFormatButton("quote", this.handleInsertQuote)}
+                {this.getFormatButton("list", this.handleInsertList)}
+                {this.getFormatButton("code", this.handleInsertCode)}
+                {this.getFormatButton("subscript", this.handleInsertSubscript)}
+                {this.getFormatButton(
+                  "superscript",
+                  this.handleInsertSuperscript
+                )}
+                {this.getFormatButton("spoiler", this.handleInsertSpoiler)}
+                <a
+                  href={markdownHelpUrl}
+                  className="btn btn-sm text-muted font-weight-bold"
+                  title={i18n.t("formatting_help")}
+                  rel={relTags}
+                >
+                  <Icon icon="help-circle" classes="icon-inline" />
+                </a>
+              </div>
+
+              <div>
+                <textarea
+                  id={this.id}
+                  className={classNames("form-control border-0 rounded-0", {
+                    "d-none": this.state.previewMode,
+                  })}
+                  value={this.state.content}
+                  onInput={linkEvent(this, this.handleContentChange)}
+                  onPaste={linkEvent(this, this.handleImageUploadPaste)}
+                  onKeyDown={linkEvent(this, this.handleKeyBinds)}
+                  required
+                  disabled={this.isDisabled}
+                  rows={2}
+                  maxLength={
+                    this.props.maxLength ?? markdownFieldCharacterLimit
+                  }
+                  placeholder={this.props.placeholder}
+                />
+                {this.state.previewMode && this.state.content && (
                   <div
                     className="card border-secondary card-body md-div"
-                    dangerouslySetInnerHTML={mdToHtml(content)}
+                    dangerouslySetInnerHTML={mdToHtml(this.state.content)}
                   />
-                ),
-                none: <></>,
-              })}
-          </div>
-          <label class="sr-only" htmlFor={this.id}>
-            {i18n.t("body")}
-          </label>
-        </div>
-        <div class="row">
-          <div class="col-sm-12 d-flex flex-wrap">
-            {this.props.buttonTitle.match({
-              some: buttonTitle => (
-                <button
-                  type="submit"
-                  class="btn btn-sm btn-secondary mr-2"
-                  disabled={this.props.disabled || this.state.loading}
-                >
-                  {this.state.loading ? (
-                    <Spinner />
-                  ) : (
-                    <span>{buttonTitle}</span>
+                )}
+                {this.state.imageUploadStatus &&
+                  this.state.imageUploadStatus.total > 1 && (
+                    <ProgressBar
+                      className="mt-2"
+                      striped
+                      animated
+                      value={this.state.imageUploadStatus.uploaded}
+                      max={this.state.imageUploadStatus.total}
+                      text={i18n.t("pictures_uploded_progess", {
+                        uploaded: this.state.imageUploadStatus.uploaded,
+                        total: this.state.imageUploadStatus.total,
+                      })}
+                    />
                   )}
-                </button>
-              ),
-              none: <></>,
-            })}
+              </div>
+              <label className="sr-only" htmlFor={this.id}>
+                {i18n.t("body")}
+              </label>
+            </div>
+          </div>
+
+          <div className="col-12 d-flex align-items-center flex-wrap mt-2">
+            {this.props.showLanguage && (
+              <LanguageSelect
+                iconVersion
+                allLanguages={this.props.allLanguages}
+                selectedLanguageIds={
+                  languageId ? Array.of(languageId) : undefined
+                }
+                siteLanguages={this.props.siteLanguages}
+                onChange={this.handleLanguageChange}
+                disabled={this.isDisabled}
+              />
+            )}
+
+            {/* A flex expander */}
+            <div className="flex-grow-1"></div>
+
+            {this.props.buttonTitle && (
+              <button
+                type="submit"
+                className="btn btn-sm btn-secondary ml-2"
+                disabled={this.isDisabled}
+              >
+                {this.state.loading ? (
+                  <Spinner />
+                ) : (
+                  <span>{this.props.buttonTitle}</span>
+                )}
+              </button>
+            )}
             {this.props.replyType && (
               <button
                 type="button"
-                class="btn btn-sm btn-secondary mr-2"
+                className="btn btn-sm btn-secondary ml-2"
                 onClick={linkEvent(this, this.handleReplyCancel)}
               >
                 {i18n.t("cancel")}
               </button>
             )}
-            {this.state.content.isSome() && (
+            {this.state.content && (
               <button
-                className={`btn btn-sm btn-secondary mr-2 ${
+                className={`btn btn-sm btn-secondary ml-2 ${
                   this.state.previewMode && "active"
                 }`}
                 onClick={linkEvent(this, this.handlePreviewToggle)}
               >
-                {i18n.t("preview")}
+                {this.state.previewMode ? i18n.t("edit") : i18n.t("preview")}
               </button>
             )}
-            {/* A flex expander */}
-            <div class="flex-grow-1"></div>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("bold")}
-              aria-label={i18n.t("bold")}
-              onClick={linkEvent(this, this.handleInsertBold)}
-            >
-              <Icon icon="bold" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("italic")}
-              aria-label={i18n.t("italic")}
-              onClick={linkEvent(this, this.handleInsertItalic)}
-            >
-              <Icon icon="italic" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("link")}
-              aria-label={i18n.t("link")}
-              onClick={linkEvent(this, this.handleInsertLink)}
-            >
-              <Icon icon="link" classes="icon-inline" />
-            </button>
-            <form class="btn btn-sm text-muted font-weight-bold">
-              <label
-                htmlFor={`file-upload-${this.id}`}
-                className={`mb-0 ${
-                  UserService.Instance.myUserInfo.isSome() && "pointer"
-                }`}
-                data-tippy-content={i18n.t("upload_image")}
-              >
-                {this.state.imageLoading ? (
-                  <Spinner />
-                ) : (
-                  <Icon icon="image" classes="icon-inline" />
-                )}
-              </label>
-              <input
-                id={`file-upload-${this.id}`}
-                type="file"
-                accept="image/*,video/*"
-                name="file"
-                class="d-none"
-                disabled={UserService.Instance.myUserInfo.isNone()}
-                onChange={linkEvent(this, this.handleImageUpload)}
-              />
-            </form>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("header")}
-              aria-label={i18n.t("header")}
-              onClick={linkEvent(this, this.handleInsertHeader)}
-            >
-              <Icon icon="header" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("strikethrough")}
-              aria-label={i18n.t("strikethrough")}
-              onClick={linkEvent(this, this.handleInsertStrikethrough)}
-            >
-              <Icon icon="strikethrough" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("quote")}
-              aria-label={i18n.t("quote")}
-              onClick={linkEvent(this, this.handleInsertQuote)}
-            >
-              <Icon icon="format_quote" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("list")}
-              aria-label={i18n.t("list")}
-              onClick={linkEvent(this, this.handleInsertList)}
-            >
-              <Icon icon="list" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("code")}
-              aria-label={i18n.t("code")}
-              onClick={linkEvent(this, this.handleInsertCode)}
-            >
-              <Icon icon="code" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("subscript")}
-              aria-label={i18n.t("subscript")}
-              onClick={linkEvent(this, this.handleInsertSubscript)}
-            >
-              <Icon icon="subscript" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("superscript")}
-              aria-label={i18n.t("superscript")}
-              onClick={linkEvent(this, this.handleInsertSuperscript)}
-            >
-              <Icon icon="superscript" classes="icon-inline" />
-            </button>
-            <button
-              class="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("spoiler")}
-              aria-label={i18n.t("spoiler")}
-              onClick={linkEvent(this, this.handleInsertSpoiler)}
-            >
-              <Icon icon="alert-triangle" classes="icon-inline" />
-            </button>
-            <a
-              href={markdownHelpUrl}
-              class="btn btn-sm text-muted font-weight-bold"
-              title={i18n.t("formatting_help")}
-              rel={relTags}
-            >
-              <Icon icon="help-circle" classes="icon-inline" />
-            </a>
           </div>
         </div>
       </form>
     );
   }
 
+  getFormatButton(
+    type: NoOptionI18nKeys,
+    handleClick: (i: MarkdownTextArea, event: any) => void
+  ) {
+    let iconType: string;
+
+    switch (type) {
+      case "spoiler": {
+        iconType = "alert-triangle";
+        break;
+      }
+      case "quote": {
+        iconType = "format_quote";
+        break;
+      }
+      default: {
+        iconType = type;
+      }
+    }
+
+    return (
+      <button
+        className="btn btn-sm text-muted"
+        data-tippy-content={i18n.t(type)}
+        aria-label={i18n.t(type)}
+        onClick={linkEvent(this, handleClick)}
+        disabled={this.isDisabled}
+      >
+        <Icon icon={iconType} classes="icon-inline" />
+      </button>
+    );
+  }
+
+  handleEmoji(i: MarkdownTextArea, e: any) {
+    let value = e.native;
+    if (value == null) {
+      const emoji = customEmojisLookup.get(e.id)?.custom_emoji;
+      if (emoji) {
+        value = `![${emoji.alt_text}](${emoji.image_url} "${emoji.shortcode}")`;
+      }
+    }
+    i.setState({
+      content: `${i.state.content ?? ""} ${value} `,
+    });
+    i.contentChange();
+    const textarea: any = document.getElementById(i.id);
+    autosize.update(textarea);
+  }
+
   handleImageUploadPaste(i: MarkdownTextArea, event: any) {
-    let image = event.clipboardData.files[0];
+    const image = event.clipboardData.files[0];
     if (image) {
       i.handleImageUpload(i, image);
     }
   }
 
   handleImageUpload(i: MarkdownTextArea, event: any) {
-    let file: any;
+    const files: File[] = [];
     if (event.target) {
       event.preventDefault();
-      file = event.target.files[0];
+      files.push(...event.target.files);
     } else {
-      file = event;
+      files.push(event);
     }
 
-    const formData = new FormData();
-    formData.append("images[]", file);
-
-    i.state.imageLoading = true;
-    i.setState(i.state);
-
-    fetch(pictrsUri, {
-      method: "POST",
-      body: formData,
-    })
-      .then(res => res.json())
-      .then(res => {
-        console.log("pictrs upload:");
-        console.log(res);
-        if (res.msg == "ok") {
-          let hash = res.files[0].file;
-          let url = `${pictrsUri}/${hash}`;
-          let deleteToken = res.files[0].delete_token;
-          let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
-          let imageMarkdown = `![](${url})`;
-          i.state.content = Some(
-            i.state.content.match({
-              some: content => `${content}\n${imageMarkdown}`,
-              none: imageMarkdown,
-            })
-          );
-          i.state.imageLoading = false;
-          i.contentChange();
-          i.setState(i.state);
-          let textarea: any = document.getElementById(i.id);
-          autosize.update(textarea);
-          pictrsDeleteToast(
-            i18n.t("click_to_delete_picture"),
-            i18n.t("picture_deleted"),
-            i18n.t("fail_picture_deleted").concat('\n(', file.name,')'),
-            deleteUrl
-          );
-        } else {
-          i.state.imageLoading = false;
-          i.setState(i.state);
-          toast(JSON.stringify(res), "danger");
-        }
-      })
-      .catch(error => {
-        i.state.imageLoading = false;
-        i.setState(i.state);
-        console.error(error);
-        toast(error, "danger");
+    if (files.length > maxUploadImages) {
+      toast(
+        i18n.t("too_many_images_upload", {
+          count: Number(maxUploadImages),
+          formattedCount: numToSI(maxUploadImages),
+        }),
+        "danger"
+      );
+    } else {
+      i.setState({
+        imageUploadStatus: { total: files.length, uploaded: 0 },
+      });
+
+      i.uploadImages(i, files).then(() => {
+        i.setState({ imageUploadStatus: undefined });
       });
+    }
   }
 
-  contentChange() {
-    if (this.props.onContentChange) {
-      this.props.onContentChange(toUndefined(this.state.content));
+  async uploadImages(i: MarkdownTextArea, files: File[]) {
+    let errorOccurred = false;
+    const filesCopy = [...files];
+    while (filesCopy.length > 0 && !errorOccurred) {
+      try {
+        await Promise.all(
+          filesCopy.splice(0, concurrentImageUpload).map(async file => {
+            await i.uploadSingleImage(i, file);
+
+            this.setState(({ imageUploadStatus }) => ({
+              imageUploadStatus: {
+                ...(imageUploadStatus as Required<ImageUploadStatus>),
+                uploaded: (imageUploadStatus?.uploaded ?? 0) + 1,
+              },
+            }));
+          })
+        );
+      } catch (e) {
+        errorOccurred = true;
+      }
+    }
+  }
+
+  async uploadSingleImage(i: MarkdownTextArea, image: File) {
+    const res = await HttpService.client.uploadImage({ image });
+    console.log("pictrs upload:");
+    console.log(res);
+    if (res.state === "success") {
+      if (res.data.msg === "ok") {
+        const imageMarkdown = `![](${res.data.url})`;
+        i.setState(({ content }) => ({
+          content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
+        }));
+        i.contentChange();
+        const textarea: any = document.getElementById(i.id);
+        autosize.update(textarea);
+        pictrsDeleteToast(image.name, res.data.delete_url as string);
+      } else {
+        throw JSON.stringify(res.data);
+      }
+    } else if (res.state === "failed") {
+      i.setState({ imageUploadStatus: undefined });
+      console.error(res.msg);
+      toast(res.msg, "danger");
+
+      throw res.msg;
     }
   }
 
+  contentChange() {
+    // Coerces the undefineds to empty strings, for replacing in the DB
+    const content = this.state.content ?? "";
+    this.props.onContentChange?.(content);
+  }
+
   handleContentChange(i: MarkdownTextArea, event: any) {
-    i.state.content = Some(event.target.value);
+    i.setState({ content: event.target.value });
     i.contentChange();
-    i.setState(i.state);
+  }
+
+  // Keybind handler
+  // Keybinds inspired by github comment area
+  handleKeyBinds(i: MarkdownTextArea, event: KeyboardEvent) {
+    if (event.ctrlKey) {
+      switch (event.key) {
+        case "k": {
+          i.handleInsertLink(i, event);
+          break;
+        }
+        case "Enter": {
+          if (!this.isDisabled) {
+            i.handleSubmit(i, event);
+          }
+
+          break;
+        }
+        case "b": {
+          i.handleInsertBold(i, event);
+          break;
+        }
+        case "i": {
+          i.handleInsertItalic(i, event);
+          break;
+        }
+        case "e": {
+          i.handleInsertCode(i, event);
+          break;
+        }
+        case "8": {
+          i.handleInsertList(i, event);
+          break;
+        }
+        case "s": {
+          i.handleInsertSpoiler(i, event);
+          break;
+        }
+        case "p": {
+          if (i.state.content) i.handlePreviewToggle(i, event);
+          break;
+        }
+        case ".": {
+          i.handleInsertQuote(i, event);
+          break;
+        }
+      }
+    }
   }
 
   handlePreviewToggle(i: MarkdownTextArea, event: any) {
     event.preventDefault();
-    i.state.previewMode = !i.state.previewMode;
-    i.setState(i.state);
+    i.setState({ previewMode: !i.state.previewMode });
+  }
+
+  handleLanguageChange(val: number[]) {
+    this.setState({ languageId: val[0] });
   }
 
   handleSubmit(i: MarkdownTextArea, event: any) {
     event.preventDefault();
-    i.state.loading = true;
-    i.setState(i.state);
-    let msg = { val: toUndefined(i.state.content), formId: i.formId };
-    i.props.onSubmit(msg);
+    if (i.state.content) {
+      i.setState({ loading: true, submitted: true });
+      i.props.onSubmit?.(i.state.content, i.formId, i.state.languageId);
+    }
   }
 
   handleReplyCancel(i: MarkdownTextArea) {
-    i.props.onReplyCancel();
+    i.props.onReplyCancel?.();
   }
 
   handleInsertLink(i: MarkdownTextArea, event: any) {
     event.preventDefault();
 
-    let textarea: any = document.getElementById(i.id);
-    let start: number = textarea.selectionStart;
-    let end: number = textarea.selectionEnd;
+    const textarea: any = document.getElementById(i.id);
+    const start: number = textarea.selectionStart;
+    const end: number = textarea.selectionEnd;
 
-    if (i.state.content.isNone()) {
-      i.state.content = Some("");
-    }
+    const content = i.state.content ?? "";
 
-    let content = i.state.content.unwrap();
+    if (!i.state.content) {
+      i.setState({ content: "" });
+    }
 
     if (start !== end) {
-      let selectedText = content.substring(start, end);
-      i.state.content = Some(
-        `${content.substring(0, start)}[${selectedText}]()${content.substring(
-          end
-        )}`
-      );
+      const selectedText = content?.substring(start, end);
+      i.setState({
+        content: `${content?.substring(
+          0,
+          start
+        )}[${selectedText}]()${content?.substring(end)}`,
+      });
       textarea.focus();
       setTimeout(() => (textarea.selectionEnd = end + 3), 10);
     } else {
-      i.state.content = Some(`${content} []()`);
+      i.setState({ content: `${content} []()` });
       textarea.focus();
       setTimeout(() => (textarea.selectionEnd -= 1), 10);
     }
     i.contentChange();
-    i.setState(i.state);
   }
 
   simpleSurround(chars: string) {
@@ -460,30 +573,28 @@ export class MarkdownTextArea extends Component<
     afterChars: string,
     emptyChars = "___"
   ) {
-    if (this.state.content.isNone()) {
-      this.state.content = Some("");
+    const content = this.state.content ?? "";
+    if (!this.state.content) {
+      this.setState({ content: "" });
     }
-    let textarea: any = document.getElementById(this.id);
-    let start: number = textarea.selectionStart;
-    let end: number = textarea.selectionEnd;
-
-    let content = this.state.content.unwrap();
+    const textarea: any = document.getElementById(this.id);
+    const start: number = textarea.selectionStart;
+    const end: number = textarea.selectionEnd;
 
     if (start !== end) {
-      let selectedText = content.substring(start, end);
-      this.state.content = Some(
-        `${content.substring(
+      const selectedText = content?.substring(start, end);
+      this.setState({
+        content: `${content?.substring(
           0,
           start
-        )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
-      );
+        )}${beforeChars}${selectedText}${afterChars}${content?.substring(end)}`,
+      });
     } else {
-      this.state.content = Some(
-        `${content}${beforeChars}${emptyChars}${afterChars}`
-      );
+      this.setState({
+        content: `${content}${beforeChars}${emptyChars}${afterChars}`,
+      });
     }
     this.contentChange();
-    this.setState(this.state);
 
     textarea.focus();
 
@@ -530,7 +641,7 @@ export class MarkdownTextArea extends Component<
 
   handleInsertList(i: MarkdownTextArea, event: any) {
     event.preventDefault();
-    i.simpleBeginningofLine("-");
+    i.simpleBeginningofLine(`-${i.getSelectedText() ? " " : ""}`);
   }
 
   handleInsertQuote(i: MarkdownTextArea, event: any) {
@@ -554,56 +665,65 @@ export class MarkdownTextArea extends Component<
   }
 
   simpleInsert(chars: string) {
-    if (this.state.content.isNone()) {
-      this.state.content = Some(`${chars} `);
+    const content = this.state.content;
+    if (!content) {
+      this.setState({ content: `${chars} ` });
     } else {
-      this.state.content = Some(`${this.state.content.unwrap()}\n${chars} `);
+      this.setState({
+        content: `${content}\n${chars} `,
+      });
     }
 
-    let textarea: any = document.getElementById(this.id);
+    const textarea: any = document.getElementById(this.id);
     textarea.focus();
     setTimeout(() => {
       autosize.update(textarea);
     }, 10);
     this.contentChange();
-    this.setState(this.state);
   }
 
   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
     event.preventDefault();
-    let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
-    let afterChars = "\n:::\n";
+    const beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
+    const afterChars = "\n:::\n";
     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
   }
 
   quoteInsert() {
-    let textarea: any = document.getElementById(this.id);
-    let selectedText = window.getSelection().toString();
+    const textarea: any = document.getElementById(this.id);
+    const selectedText = window.getSelection()?.toString();
+    const { content } = this.state;
     if (selectedText) {
-      let quotedText =
+      const quotedText =
         selectedText
           .split("\n")
           .map(t => `> ${t}`)
           .join("\n") + "\n\n";
-      if (this.state.content.isNone()) {
-        this.state.content = Some("");
+      if (!content) {
+        this.setState({ content: "" });
       } else {
-        this.state.content = Some(`${this.state.content.unwrap()}\n`);
+        this.setState({ content: `${content}\n` });
       }
-      this.state.content = Some(`${this.state.content.unwrap()}${quotedText}`);
+      this.setState({
+        content: `${content}${quotedText}`,
+      });
       this.contentChange();
-      this.setState(this.state);
       // Not sure why this needs a delay
       setTimeout(() => autosize.update(textarea), 10);
     }
   }
 
   getSelectedText(): string {
-    let textarea: any = document.getElementById(this.id);
-    let start: number = textarea.selectionStart;
-    let end: number = textarea.selectionEnd;
-    return start !== end
-      ? this.state.content.unwrap().substring(start, end)
-      : "";
+    const { selectionStart: start, selectionEnd: end } =
+      document.getElementById(this.id) as any;
+    return start !== end ? this.state.content?.substring(start, end) ?? "" : "";
+  }
+
+  get isDisabled() {
+    return (
+      this.state.loading ||
+      this.props.disabled ||
+      !!this.state.imageUploadStatus
+    );
   }
 }