]> Untitled Git - lemmy-ui.git/blobdiff - src/shared/components/common/markdown-textarea.tsx
Merge branch 'main' into breakout-role-utils
[lemmy-ui.git] / src / shared / components / common / markdown-textarea.tsx
index 8cdfaadc33b0dafd82aee0241ee037681b3bf9f1..4e1bca11f82c9f0b434705350f34665eb83ca541 100644 (file)
@@ -1,15 +1,17 @@
-import { None, Option, Some } from "@sniptt/monads";
 import autosize from "autosize";
+import { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
-import { Prompt } from "inferno-router";
-import { Language, 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 {
-  isBrowser,
+  concurrentImageUpload,
+  customEmojisLookup,
+  markdownFieldCharacterLimit,
   markdownHelpUrl,
+  maxUploadImages,
   mdToHtml,
+  numToSI,
   pictrsDeleteToast,
   randomStr,
   relTags,
@@ -17,37 +19,43 @@ import {
   setupTribute,
   toast,
 } from "../../utils";
+import { isBrowser } from "../../utils/browser/is-browser";
+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>;
-  initialLanguageId: Option<number>;
-  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: Option<string>;
-    formId: string;
-    languageId: Option<number>;
-  }): any;
-  allLanguages: Language[];
+  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>;
-  languageId: Option<number>;
+  content?: string;
+  languageId?: number;
   previewMode: boolean;
+  imageUploadStatus?: ImageUploadStatus;
   loading: boolean;
-  imageLoading: boolean;
+  submitted: boolean;
 }
 
 export class MarkdownTextArea extends Component<
@@ -57,12 +65,13 @@ export class MarkdownTextArea extends Component<
   private id = `comment-textarea-${randomStr()}`;
   private formId = `comment-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) {
@@ -73,16 +82,15 @@ export class MarkdownTextArea extends Component<
     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.setState({ content: Some(textarea.value) });
+        this.setState({ content: textarea.value });
         autosize.update(textarea);
       });
 
@@ -97,154 +105,100 @@ 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.setState({ previewMode: false, loading: false, content: None });
+      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);
     }
   }
 
-  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 className="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)}
+              value={this.state.content}
               onInput={linkEvent(this, this.handleContentChange)}
               onPaste={linkEvent(this, this.handleImageUploadPaste)}
+              onKeyDown={linkEvent(this, this.handleKeyBinds)}
               required
-              disabled={this.props.disabled}
+              disabled={this.isDisabled}
               rows={2}
-              maxLength={this.props.maxLength.unwrapOr(10000)}
-              placeholder={toUndefined(this.props.placeholder)}
+              maxLength={this.props.maxLength ?? markdownFieldCharacterLimit}
+              placeholder={this.props.placeholder}
             />
-            {this.state.previewMode &&
-              this.state.content.match({
-                some: content => (
-                  <div
-                    className="card border-secondary card-body md-div"
-                    dangerouslySetInnerHTML={mdToHtml(content)}
-                  />
-                ),
-                none: <></>,
-              })}
+            {this.state.previewMode && this.state.content && (
+              <div
+                className="card border-secondary card-body md-div"
+                dangerouslySetInnerHTML={mdToHtml(this.state.content)}
+              />
+            )}
+            {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,
+                  })}
+                />
+              )}
           </div>
           <label className="sr-only" htmlFor={this.id}>
             {i18n.t("body")}
           </label>
         </div>
-        {this.props.showLanguage && (
-          <div className="row justify-content-end">
-            <div className="col-sm-8">
-              <LanguageSelect
-                allLanguages={this.props.allLanguages}
-                selectedLanguageIds={this.state.languageId.map(Array.of)}
-                multiple={false}
-                onChange={this.handleLanguageChange}
-              />
-            </div>
-          </div>
-        )}
         <div className="row">
           <div className="col-sm-12 d-flex flex-wrap">
-            {this.props.buttonTitle.match({
-              some: buttonTitle => (
-                <button
-                  type="submit"
-                  className="btn btn-sm btn-secondary mr-2"
-                  disabled={this.props.disabled || this.state.loading}
-                >
-                  {this.state.loading ? (
-                    <Spinner />
-                  ) : (
-                    <span>{buttonTitle}</span>
-                  )}
-                </button>
-              ),
-              none: <></>,
-            })}
-            {this.props.replyType && (
-              <button
-                type="button"
-                className="btn btn-sm btn-secondary mr-2"
-                onClick={linkEvent(this, this.handleReplyCancel)}
-              >
-                {i18n.t("cancel")}
-              </button>
-            )}
-            {this.state.content.isSome() && (
-              <button
-                className={`btn btn-sm btn-secondary mr-2 ${
-                  this.state.previewMode && "active"
-                }`}
-                onClick={linkEvent(this, this.handlePreviewToggle)}
-              >
-                {i18n.t("preview")}
-              </button>
-            )}
-            {/* A flex expander */}
-            <div className="flex-grow-1"></div>
-            <button
-              className="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
-              className="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
-              className="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>
+            {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.isSome() && "pointer"
+                  UserService.Instance.myUserInfo && "pointer"
                 }`}
                 data-tippy-content={i18n.t("upload_image")}
               >
-                {this.state.imageLoading ? (
+                {this.state.imageUploadStatus ? (
                   <Spinner />
                 ) : (
                   <Icon icon="image" classes="icon-inline" />
@@ -256,74 +210,22 @@ export class MarkdownTextArea extends Component<
                 accept="image/*,video/*"
                 name="file"
                 className="d-none"
-                disabled={UserService.Instance.myUserInfo.isNone()}
+                multiple
+                disabled={!UserService.Instance.myUserInfo || this.isDisabled}
                 onChange={linkEvent(this, this.handleImageUpload)}
               />
             </form>
-            <button
-              className="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
-              className="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
-              className="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
-              className="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
-              className="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
-              className="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
-              className="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
-              className="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>
+            {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"
@@ -333,137 +235,300 @@ export class MarkdownTextArea extends Component<
               <Icon icon="help-circle" classes="icon-inline" />
             </a>
           </div>
+
+          <div className="col-sm-12 d-flex align-items-center flex-wrap">
+            {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 mr-2"
+                disabled={this.isDisabled}
+              >
+                {this.state.loading ? (
+                  <Spinner />
+                ) : (
+                  <span>{this.props.buttonTitle}</span>
+                )}
+              </button>
+            )}
+            {this.props.replyType && (
+              <button
+                type="button"
+                className="btn btn-sm btn-secondary mr-2"
+                onClick={linkEvent(this, this.handleReplyCancel)}
+              >
+                {i18n.t("cancel")}
+              </button>
+            )}
+            {this.state.content && (
+              <button
+                className={`btn btn-sm btn-secondary mr-2 ${
+                  this.state.previewMode && "active"
+                }`}
+                onClick={linkEvent(this, this.handlePreviewToggle)}
+              >
+                {this.state.previewMode ? i18n.t("edit") : i18n.t("preview")}
+              </button>
+            )}
+          </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.setState({ imageLoading: true });
-
-    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.setState({
-            content: Some(
-              i.state.content.match({
-                some: content => `${content}\n${imageMarkdown}`,
-                none: imageMarkdown,
-              })
-            ),
-            imageLoading: false,
-          });
-          i.contentChange();
-          let textarea: any = document.getElementById(i.id);
-          autosize.update(textarea);
-          pictrsDeleteToast(
-            `${i18n.t("click_to_delete_picture")}: ${file.name}`,
-            `${i18n.t("picture_deleted")}: ${file.name}`,
-            `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
-            deleteUrl
-          );
-        } else {
-          i.setState({ imageLoading: false });
-          toast(JSON.stringify(res), "danger");
-        }
-      })
-      .catch(error => {
-        i.setState({ imageLoading: false });
-        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.setState({ content: Some(event.target.value) });
+    i.setState({ content: event.target.value });
     i.contentChange();
   }
 
+  // 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.setState({ previewMode: !i.state.previewMode });
   }
 
   handleLanguageChange(val: number[]) {
-    this.setState({ languageId: Some(val[0]) });
+    this.setState({ languageId: val[0] });
   }
 
   handleSubmit(i: MarkdownTextArea, event: any) {
     event.preventDefault();
-    i.setState({ loading: true });
-    let msg = {
-      val: i.state.content,
-      formId: i.formId,
-      languageId: i.state.languageId,
-    };
-    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.setState({ 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);
+      const selectedText = content?.substring(start, end);
       i.setState({
-        content: Some(
-          `${content.substring(0, start)}[${selectedText}]()${content.substring(
-            end
-          )}`
-        ),
+        content: `${content?.substring(
+          0,
+          start
+        )}[${selectedText}]()${content?.substring(end)}`,
       });
       textarea.focus();
       setTimeout(() => (textarea.selectionEnd = end + 3), 10);
     } else {
-      i.setState({ content: Some(`${content} []()`) });
+      i.setState({ content: `${content} []()` });
       textarea.focus();
       setTimeout(() => (textarea.selectionEnd -= 1), 10);
     }
@@ -483,28 +548,25 @@ export class MarkdownTextArea extends Component<
     afterChars: string,
     emptyChars = "___"
   ) {
-    if (this.state.content.isNone()) {
-      this.setState({ 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);
+      const selectedText = content?.substring(start, end);
       this.setState({
-        content: Some(
-          `${content.substring(
-            0,
-            start
-          )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}`
-        ),
+        content: `${content?.substring(
+          0,
+          start
+        )}${beforeChars}${selectedText}${afterChars}${content?.substring(end)}`,
       });
     } else {
       this.setState({
-        content: Some(`${content}${beforeChars}${emptyChars}${afterChars}`),
+        content: `${content}${beforeChars}${emptyChars}${afterChars}`,
       });
     }
     this.contentChange();
@@ -554,7 +616,7 @@ export class MarkdownTextArea extends Component<
 
   handleInsertList(i: MarkdownTextArea, event: any) {
     event.preventDefault();
-    i.simpleBeginningofLine("-");
+    i.simpleBeginningofLine(`-${i.getSelectedText() ? " " : ""}`);
   }
 
   handleInsertQuote(i: MarkdownTextArea, event: any) {
@@ -578,15 +640,16 @@ export class MarkdownTextArea extends Component<
   }
 
   simpleInsert(chars: string) {
-    if (this.state.content.isNone()) {
-      this.setState({ content: Some(`${chars} `) });
+    const content = this.state.content;
+    if (!content) {
+      this.setState({ content: `${chars} ` });
     } else {
       this.setState({
-        content: Some(`${this.state.content.unwrap()}\n${chars} `),
+        content: `${content}\n${chars} `,
       });
     }
 
-    let textarea: any = document.getElementById(this.id);
+    const textarea: any = document.getElementById(this.id);
     textarea.focus();
     setTimeout(() => {
       autosize.update(textarea);
@@ -596,27 +659,28 @@ export class MarkdownTextArea extends Component<
 
   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.setState({ content: Some("") });
+      if (!content) {
+        this.setState({ content: "" });
       } else {
-        this.setState({ content: Some(`${this.state.content.unwrap()}\n`) });
+        this.setState({ content: `${content}\n` });
       }
       this.setState({
-        content: Some(`${this.state.content.unwrap()}${quotedText}`),
+        content: `${content}${quotedText}`,
       });
       this.contentChange();
       // Not sure why this needs a delay
@@ -625,11 +689,16 @@ export class MarkdownTextArea extends Component<
   }
 
   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
+    );
   }
 }