Source: src/js/views/MarkdownEditorView.js

define([
  "underscore",
  "jquery",
  "backbone",
  "woofmark",
  "models/metadata/eml220/EMLText",
  "views/ImageUploaderView",
  "views/MarkdownView",
  "views/TableEditorView",
  "text!templates/markdownEditor.html",
], (
  _,
  $,
  Backbone,
  Woofmark,
  EMLText,
  ImageUploader,
  MarkdownView,
  TableEditor,
  Template,
) => {
  // So that we can assign properties to Woofmark
  const woofmark = Woofmark;

  // Set the default text for collapsible sections
  Woofmark.strings.placeholders.detailsSummary = "Click to expand/collapse";
  Woofmark.strings.placeholders.detailsContent = "Details here";
  Woofmark.strings.titles.details = "Collapsible section";

  /**
   * @class MarkdownEditorView
   * @classdesc A view of an HTML textarea with markdown editor UI and preview tab
   * @classcategory Views
   * @augments Backbone.View
   * @class
   */
  const MarkdownEditorView = Backbone.View.extend(
    /** @lends MarkdownEditorView.prototype */ {
      /**
       * The type of View this is
       * @type {string}
       * @readonly
       */
      type: "MarkdownEditor",

      /**
       * The HTML classes to use for this view's element
       * @type {string}
       */
      className: "markdown-editor",

      /**
       * References to templates for this view. HTML files are converted to
       * Underscore.js templates
       * @type {Underscore.Template}
       */
      template: _.template(Template),

      /*
       * Markdown to insert into the textarea when the view is first rendered
       * @type {string}
       */
      // markdown: "",

      /**
       * EMLText model that contains a markdown attribute to edit. The markdown is
       * inserted into the textarea when the view is first rendered. If there's no markdown,
       * then the view looks for markdown from the markdownExample attribute in the model.
       * Note that if there are multiple markdown strings in the model, only the first
       * is rendered/edited.
       * @type {EMLText}
       */
      model: null,

      /**
       * The placeholder text to display in the textarea when it's empty
       * @type {string}
       */
      markdownPlaceholder: "",

      /**
       * The placeholder text to display in the preview area when there's no
       * markdown
       * @type {string}
       */
      previewPlaceholder: "",

      /**
       * Indicates whether or not to render a table of contents for the markdown
       * preview. If set to true, a table of contents will be shown in the preview
       * if there two or more top-level headers are rendered from the markdown.
       * @type {boolean}
       */
      showTOC: false,

      /**
       * The maximum height for uploaded image files. If a file is taller than this, it
       * will be resized without warning before being uploaded. If set to null,
       * the image won't be resized based on height (but might be depending on
       * maxImageWidth).
       * @type {number}
       * @default 1200
       * @since 2.15.0
       */
      maxImageHeight: 1200,

      /**
       * The maximum width for uploaded image files. If a file is wider than this, it
       * will be resized without warning before being uploaded. If set to null,
       * the image won't be resized based on width (but might be depending on
       * maxImageHeight).
       * @type {number}
       * @default 1200
       * @since 2.15.0
       */
      maxImageWidth: 1200,

      /**
       * A jQuery selector for the HTML textarea element that will contain the
       * markdown text.
       * @type {string}
       */
      textarea: ".markdown-textarea",

      /**
       * The events this view will listen to and the associated function to call.
       * @type {object}
       */
      events: {
        "click #markdown-preview-link": "previewMarkdown",
        "focusout .markdown-textarea": "updateMarkdown",
      },

      /**
       * Initialize is executed when a new markdownEditor is created.
       * @param {object} options - A literal object with options to pass to the view
       */
      initialize(options) {
        if (typeof options !== "undefined") {
          this.model = options.model || new EMLText();
          this.markdownPlaceholder = options.markdownPlaceholder || "";
          this.previewPlaceholder = options.previewPlaceholder || "";
          this.showTOC = options.showTOC || false;
        }
      },

      /**
       * render - Renders the markdownEditor - add UI for adding and editing
       * markdown to a textarea
       */
      render() {
        // Save the view
        const view = this;

        // The markdown attribute in the model may be a string or an array of strings.
        // Although EML211 can comprise an array of markdown elements,
        // this view will only render/edit the first if there are multiple.
        let markdown = this.model.get("markdown");
        if (Array.isArray(markdown) && markdown.length) {
          [markdown] = markdown;
        }
        if (!markdown || !markdown.length) {
          markdown = this.model.get("markdownExample");
        }

        // Insert the template into the view
        this.$el
          .html(
            this.template({
              markdown: markdown || "",
              markdownPlaceholder: this.markdownPlaceholder || "",
              previewPlaceholder: this.previewPlaceholder || "",
              cid: this.cid,
            }),
          )
          .data("view", this);

        // The textarea element that the markdown editor buttons & functions will edit
        let textarea = this.$el.find(this.textarea);

        if (textarea && textarea.length) {
          textarea = textarea.get(0); // Get the DOM element from the jQuery object
        }

        if (!textarea) return;

        // Set woofmark options. See https://github.com/bevacqua/woofmark
        const woofmarkOptions = {
          fencing: true,
          html: false,
          wysiwyg: false,
          defaultMode: "markdown",
          render: {
            // Hide buttons that switch between markdown, WYSIWYG, & HTML for now
            modes(button, _id) {
              button.remove();
            },
          },
        };

        // Set options for all the buttons that will be shown in the toolbar.
        // Buttons will be shown in the order they are listed.
        // Defaults from Woofmark will be used unless they are replaced here,
        // see: https://github.com/bevacqua/woofmark/blob/master/src/strings.js.
        // They key is the ID for the button.
        //    remove: if set to true, the button will be removed (use this to hide default woofmark buttons)
        //    icon: the name of the font awesome icon to show in the button. If no button or svg is set, the ID/key will be displayed instead.
        //    svg: svg code to show in the button. If no button or svg is set, the ID/key will be displayed instead.
        //    title: The title to show on hover
        //    function: The function to call when the button is pressed. It will be passed chunks, cmd, e (see Woofmark docs), plus the ID/key. Called with view as the this (context).
        //    shortcut: The keyboard shortcut to use for the button. This will only work if there is also a custom function set.
        //    insertDividerAfter: If set to true, a visual divider will be placed after this button.
        const buttonOptions = {
          // Default woofmark buttons to remove
          attachment: {
            remove: true,
          },
          heading: {
            remove: true,
          },
          hr: {
            remove: true,
          },
          // Remove the default image uploader button so we can add our own that
          // uploads the image as a dataone object.
          image: {
            remove: true,
          },
          // Default woofmark buttons to keep, with custom properties, + custom buttons
          bold: {
            icon: "bold",
          },
          italic: {
            icon: "italic",
          },
          strike: {
            title: "Strikethrough",
            icon: "strikethrough",
            shortcut: "Ctrl+Shift+X",
            function: view.strikethrough,
            insertDividerAfter: true,
          },
          h1: {
            svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 2.9V0h7.8v2.9l-2 .5v7h7.7v-7l-2-.5V0h7.7v2.9l-2 .5v17.2l2 .5V24h-7.8v-2.9l2-.5V14H5.9v6.6l2 .5V24H0v-2.9l2-.5V3.4z"/><path fill-rule="nonzero" d="M24 16.4v-1.9h-1.4V5.8h-4.1v1.8H20v7h-1.4v1.8z"/></svg>`,
            title: "Top-level heading",
            function: view.addHeader,
          },
          h2: {
            svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill-rule="nonzero" d="M23.2 17.3l.1-3.1h-2v1.3H18c.1-.7.8-1.5 2-2.2L22 12a4 4 0 001-1l.3-1.5c0-.9-.3-1.6-1-2.1-.6-.5-1.5-.8-2.6-.8-1.3 0-2.2.3-2.9 1-.6.6-1 1.5-1 2.8l2.2.1c0-.8.2-1.3.4-1.7.3-.3.6-.5 1.1-.5.4 0 .7.1.9.3.2.2.3.5.3.8 0 .4-.1.7-.4 1-.2.4-.6.7-1.1 1a17 17 0 00-2.1 1.9c-.5.5-.8 1-1 1.6-.3.6-.4 1.4-.4 2.3h7.6z"/><path d="M.5 4.6V2.3h6.3v2.3L5.2 5v5.6h6.2V5l-1.6-.4V2.3H16v2.3l-1.6.4v14l1.6.4v2.3H9.8v-2.3l1.6-.4v-5.3H5.2V19l1.6.4v2.3H.5v-2.3l1.6-.4V5z"/></svg>`,
            title: "Second-level heading",
            function: view.addHeader,
          },
          h3: {
            svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill-rule="nonzero" d="M18.1 17.3c.7 0 1.4-.1 2-.4.6-.2 1-.6 1.4-1 .3-.6.5-1.1.5-1.7 0-1.2-.7-2-2-2.5 1-.5 1.6-1.2 1.6-2.2 0-.9-.4-1.6-1-2-.6-.6-1.5-.8-2.6-.8-1 0-1.9.3-2.5.8a3 3 0 00-1 2.2l2.1.1c0-.9.4-1.3 1.3-1.3.3 0 .6 0 .9.3.2.2.3.4.3.8s-.2.7-.5.9a3 3 0 01-1.5.3v1.9h.6c.5 0 1 0 1.2.3.3.3.5.7.5 1 0 .5-.2.8-.4 1.1-.3.3-.7.5-1.1.5-1 0-1.5-.6-1.5-1.8l-2.2.1c.1 2.3 1.4 3.4 4 3.4z"/><path d="M2 6.6V4.8h4.7v1.8l-1.2.3V11H10V6.9l-1.2-.3V4.8h4.6v1.8l-1.2.3V17l1.2.3v1.8H8.9v-1.8l1.2-.3v-3.9H5.5v4l1.2.2v1.8H2v-1.8l1.2-.3V7z"/></svg>`,
            title: "Tertiary heading",
            function: view.addHeader,
            insertDividerAfter: true,
          },
          divider: {
            svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="22" height="3" x="1" y="10.58" fill-rule="evenodd"/></svg>`,
            function: view.addDivider,
            title: "Top-level heading",
            shortcut: "Ctrl+Enter",
          },
          ol: {
            title: "Ordered list",
            icon: "list-ol",
          },
          ul: {
            title: "Un-ordered list",
            icon: "list-ul",
            insertDividerAfter: true,
          },
          quote: {
            icon: "quote-left",
          },
          code: {
            icon: "code",
            insertDividerAfter: true,
          },
          link: {
            icon: "link",
          },
          d1Image: {
            icon: "picture",
            function: view.addMdImage,
            title: "Image",
            // use Ctrl+G to overwrite the built-in woofmark image function
            shortcut: "Ctrl+G",
          },
          table: {
            icon: "table",
            function: view.addTable,
            insertDividerAfter: true,
          },
          details: {
            icon: "collapse",
            title: Woofmark.strings.titles.details,
            function(e, mode, chunks, _id) {
              // Add a collapsible section to the markdown
              const summary = Woofmark.strings.placeholders.detailsSummary;
              const content =
                chunks.selection ||
                Woofmark.strings.placeholders.detailsContent;
              const details = `<details>\n  <summary>${summary}</summary>\n  ${content}\n</details>`;
              chunks.selection = details;
              chunks.skip({ before: 0, after: 0 });
            },
          },
        };

        const buttonKeys = Object.keys(buttonOptions);

        // PRE-RENDER WOOFMARK Set titles on buttons before the Woofmark text
        // editor is rendered. This way we can use Woofmark's built-in
        // functionality to convert "Ctrl" to "Cmd" symbol if user is on mac.
        _.each(
          buttonKeys,
          (key, _i) => {
            const options = buttonOptions[key];
            const title =
              options.title || key.charAt(0).toUpperCase() + key.slice(1);

            const shortcuts = Woofmark.strings.titles[key]?.match(/Ctrl\+.*$/);
            const presetShortcut = shortcuts ? shortcuts[0] : "";
            const shortcut = options.shortcut || presetShortcut;

            if (title) {
              woofmark.strings.titles[key] = [title, shortcut].join(" ");
            }
            // So that we can identify buttons that we want to manipulate after
            // they are rendered, use the key as the button text for now.
            woofmark.strings.buttons[key] = key;
          },
          this,
        );

        // RENDER WOOFMARK
        // Initialize the woofmark markdown editor
        this.markdownEditor = new Woofmark(textarea, woofmarkOptions);

        // POST-RENDER WOOFMARK
        // After the markdown editor is initialized..

        // Add custom functions
        _.each(buttonKeys, (key, _i) => {
          const options = buttonOptions[key];
          if (options.function) {
            // addCommandButton uses cmd, not ctrl
            let shortcut = "";
            if (options.shortcut) {
              shortcut = options.shortcut.replace("Ctrl", "cmd");
            }
            view.markdownEditor.addCommandButton(
              key,
              shortcut,
              (e, mode, chunks) => {
                options.function.call(view, e, mode, chunks, key);
              },
            );
          }
        });

        // Modify the button order & appearance
        const buttonContainer = $(view.markdownEditor.textarea)
          .parent()
          .find(".wk-commands");

        _.each(buttonKeys, (key, _i) => {
          // Re-order buttons based on the order in buttonOptions, and remove
          // any that are marked for removal
          const options = buttonOptions[key];
          const buttonEl = buttonContainer
            .find(".wk-command")
            .filter(function filter() {
              return this.innerHTML === key;
            });
          if (options.remove !== true) {
            // Add tooltip
            buttonEl.tooltip({
              placement: "top",
              delay: 500,
              trigger: "hover",
            });
            // Add font awesome icon or SVG
            if (options.icon) {
              buttonEl.html(`<i class='icon-${options.icon}'></i>`);
            } else if (options.svg) {
              buttonEl.html(options.svg);
              buttonEl.find("svg").height("13px").width("auto");
            }
            buttonContainer.append(buttonEl);
            if (options.insertDividerAfter === true) {
              buttonContainer.append("<div class='wk-commands-divider'></div>");
            }
          } else {
            buttonEl.remove();
          }
        });
      },

      /**
       * addHeader - description
       * @param  {event}  e      is the original event object
       * @param  {string} mode   can be markdown, html, or wysiwyg
       * @param  {object} chunksObj is a chunks object, describing the current state of the editor, see https://github.com/bevacqua/woofmark#chunks
       * @param  {string} id     the ID of the function, set as they key in buttonOptions in the render function
       */
      addHeader(e, mode, chunksObj, id) {
        // Get the header level from the ID
        const levelToCreate = parseInt(id.replace(/^\D+/g, ""), 10);
        const chunks = chunksObj;

        chunks.selection = chunks.selection
          .replace(/\s+/g, " ")
          .replace(/(^\s+|\s+$)/g, "");

        if (!chunks.selection) {
          chunks.startTag = `${new Array(levelToCreate + 1).join("#")} `;
          chunks.selection = Woofmark.strings.placeholders.heading;
          chunks.endTag = "";
          chunks.skip({ before: 1, after: 1 });
          return;
        }

        chunks.findTags(/#+[ ]*/, /[ ]*#+/);

        chunks.startTag = "";
        chunks.endTag = "";
        chunks.findTags(null, /\s?(-+|=+)/);

        // chunks.startTag = chunks.endTag = "";
        chunks.skip({ before: 1, after: 1 });

        if (levelToCreate > 0) {
          chunks.startTag = `${new Array(levelToCreate + 1).join("#")} `;
        }
      },

      /**
       * addDivider – add or remove a horizontal-rule divider.
       * @param {Event}  e          original event (unused here)
       * @param {string} mode       'markdown' | 'html' | 'wysiwyg'  (unused here)
       * @param {object} chunksObj  Woofmark “chunks” describing the editor state
       */
      addDivider(e, mode, chunksObj) {
        const chunks = chunksObj;
        const markdown = `${chunks.before}${chunks.selection}${chunks.after}`;

        const startSel = chunks.before.length;
        const endSel = startSel + chunks.selection.length + 1;

        const dividerRE = /(?:\r\n|\r|\n){2}-{3,}/gm;

        // Collect all divider positions without loops / generators
        const dividers = [];
        markdown.replace(dividerRE, (fullMatch, _unused, offset) => {
          dividers.push({
            start: offset + 2,
            end: offset + fullMatch.length + 1,
          });
          return fullMatch; // leave text unchanged
        });

        // Does the current selection touch any divider?
        const hit = dividers.find(
          ({ start, end }) =>
            (endSel > start && endSel <= end) || // cursor end inside divider
            (startSel < end && startSel >= start), // cursor start inside divider
        );

        if (hit) {
          // remove the overlapping divider
          chunks.before = `${markdown.slice(0, hit.start - 2)}\n`;
          chunks.selection = "";
          chunks.after = markdown.slice(hit.end);
        } else {
          // insert a new divider
          const needsExtraNewline = /(\r\n|\r|\n){1}$/.test(chunks.before);
          const dividerToAdd = `${needsExtraNewline ? "\n" : "\n\n"}--------------------\n`;
          chunks.before += dividerToAdd;
        }
      },

      /**
       * addTable - Creates the UI for editing and adding tables to the textarea.
       * Detects whether the selection contained any part of a markdown table,
       * then opens a woofmark dialog box and inserts a table editor view. If a
       * table was selected, the table information is imported into the table
       * editor where the user can edit it. If no table was selected, then it
       * creates an empty table where the user can add data.
       * addTable – open the table-editor dialog and import or insert a markdown table
       * @param {Event}  e          Original click/command event (unused here)
       * @param {string} mode       'markdown' | 'html' | 'wysiwyg'  (unused)
       * @param {object} chunksObj  Woofmark “chunks” describing the editor state
       */
      addTable(e, mode, chunksObj) {
        const chunks = chunksObj;

        /* ---------- open the (link) dialog that we’ll repurpose ---------- */
        this.markdownEditor.showLinkDialog();

        const dialog = $(".wk-prompt");
        const dialogContent = dialog.find(".wk-prompt-input-container");
        const dialogTitle = dialog.find(".wk-prompt-title");
        const dialogDescription = dialog.find(".wk-prompt-description");
        const dialogOkBtn = dialog.find(".wk-prompt-ok");

        /* ---------- detect a table inside the current selection ---------- */
        const markdown = `${chunks.before}${chunks.selection}${chunks.after}`;
        const startSel = chunks.before.length;
        const endSel = startSel + chunks.selection.length + 1;

        const tableRE =
          /((\|[^|\r\n]*)+\|(?:\r?\n|\r)?)+((?:\s*\|\s*:?\s*[-=]+\s*:?\s*)+\|)(\n\s*(?:\|[^\n]+\|\r?\n?)*)?/gm;

        // Harvest every table match (no loops, no generators)
        const tables = [];
        markdown.replace(tableRE, (full, _1, _2, _3, _4, offset) => {
          tables.push({ start: offset, end: offset + full.length, text: full });
          return full; // leave source untouched
        });

        // First table whose span intersects the cursor/selection
        const hit = tables.find(
          ({ start, end }) =>
            (endSel > start && endSel <= end) ||
            (startSel < end && startSel >= start),
        );

        let tableString = "";
        if (hit) {
          ({ text: tableString } = hit);
          chunks.before = markdown.slice(0, hit.start);
          chunks.selection = markdown.slice(hit.start, hit.end);
          chunks.after = markdown.slice(hit.end);
        }

        /* ---------- launch the TableEditor view ---------- */
        const tableEditor = new TableEditor({ markdown: tableString });
        tableEditor.render();

        dialogContent.html(tableEditor.el);
        dialogDescription.remove();
        dialogTitle.text("Insert Table");

        /* ---------- when user clicks OK, insert/replace the table ---------- */
        dialogOkBtn.off("click").on("click", () => {
          const tableMarkdown = tableEditor.getMarkdown();
          // Use Woofmark's runCommand so undo/redo work as expected
          this.markdownEditor.runCommand((chnks /* current */, _md) => {
            const c = chnks || {};
            c.before = chunks.before;
            c.after = chunks.after;
            c.selection = tableMarkdown;
          });
        });
      },

      /**
       * addMdImage - The function that gets called when a user clicks the custom
       * add image button added to the markdown editor. It uses the UI created by
       * the ImageUploaderView to allow a user to select & upload an image to the
       * repository, and uses Woofmark's built-in add image functionality to
       * insert the correct markdown into the textarea. This function must be
       * called such that "this" is the markdownEditor view.
       */
      addMdImage() {
        const view = this;

        // Show woofmark's default image upload dialog, inserted at the end of body
        view.markdownEditor.showImageDialog();

        // Select the image upload dialog elements so that we can customize it
        const imageDialog = $(".wk-prompt");
        const imageDialogInput = imageDialog.find(".wk-prompt-input");
        const imageDialogDescription = imageDialog.find(
          ".wk-prompt-description",
        );
        const imageDialogOkBtn = imageDialog.find(".wk-prompt-ok");
        // Save the inner HTML of the button for when we replace it
        // temporarily during image upload
        const imageDialogOkBtnTxt = imageDialogOkBtn.html();

        // Create an ImageUploaderView and insert into this view.
        const mdImageUploader = new ImageUploader({
          uploadInstructions: "Drag & drop an image here or click to upload",
          imageTagName: "img",
          height: "175",
          width: "300",
          maxHeight: view.maxImageHeight || null,
          maxWidth: view.maxImageWidth || null,
        });

        // Show when image is uploading; temporarily disable the OK button
        view.stopListening(mdImageUploader, "addedfile");
        view.listenTo(mdImageUploader, "addedfile", () => {
          // Disable the button during upload;
          imageDialogOkBtn.prop("disabled", true);
          imageDialogOkBtn.css({ opacity: "0.5", cursor: "not-allowed" });
          imageDialogOkBtn.html(
            "<i class='icon-spinner icon-spin icon-large loading icon'></i> " +
              "Uploading...",
          );
        });

        // Update the image input URL when the image is successfully uploaded
        view.stopListening(mdImageUploader, "successSaving");
        view.listenTo(mdImageUploader, "successSaving", (dataONEObject) => {
          // Execute the DataONEObject function that performs various functions after
          // a successful save
          dataONEObject.onSuccessfulSave();

          // Re-enable the button
          imageDialogOkBtn.prop("disabled", false);
          imageDialogOkBtn.html(imageDialogOkBtnTxt);
          imageDialogOkBtn.css({ opacity: "1", cursor: "pointer" });

          // Get the uploaded image's url.
          // var url = dataONEObject.url();
          let url = "";

          if (MetacatUI.appModel.get("isCN")) {
            let sourceRepo;

            // Use the object service URL from the origin MN/datasource
            if (dataONEObject.get("datasource")) {
              sourceRepo = MetacatUI.nodeModel.getMember(
                dataONEObject.get("datasource"),
              );
            }
            // Use the object service URL from the alt repo
            if (!sourceRepo) {
              sourceRepo = MetacatUI.appModel.getActiveAltRepo();
            }

            if (sourceRepo) {
              url = sourceRepo.objectServiceUrl;
            }
          }

          // If this MetacatUI deployment is pointing to a MN, use the meta service URL from the AppModel
          if (!url) {
            url =
              MetacatUI.appModel.get("objectServiceUrl") ||
              MetacatUI.appModel.get("resolveServiceUrl");
          }

          url += dataONEObject.get("id");

          // Create title out of file name without extension.
          let title = dataONEObject.get("fileName");
          if (title && title.lastIndexOf(".") > 0) {
            title = title.substring(0, title.lastIndexOf("."));
          }

          // Add the url + title to the input
          imageDialogInput.val(`${url} "${title}"`);
        });

        // Clear the input when the image is removed
        view.stopListening(mdImageUploader, "removedfile");
        view.listenTo(mdImageUploader, "removedfile", () => {
          imageDialogInput.val("");
        });

        // Render the image uploader and insert it just after the upload
        // instructions in the image upload dialog box.
        mdImageUploader.render();
        // The instructions for uploading in image that displays in the prompt/dialog
        imageDialogDescription.text("Click or drag & drop to upload an image");
        $(mdImageUploader.el).insertAfter(imageDialogDescription);
        // Hide the input box for now, to keep the uploader simple
        imageDialogInput.hide();
      },

      /**
       * strikethrough - Add or remove the markdown syntax for strike through to
       * the textarea. If there is text selected, then strike through formatting
       * will be added or removed from that selection. If no selection,
       * some placeholder text will be added surrounded by the strikethrough
       * delimiters.
       * @param  {event} e      is the original event object
       * @param  {string} mode   can be markdown, html, or wysiwyg
       * @param  {object} chunksObj is a chunks object, describing the current state of the editor, see https://github.com/bevacqua/woofmark#chunks
       */
      strikethrough(e, mode, chunksObj) {
        const chunks = chunksObj;
        const markup = "~~";
        // exactly two tiles
        // const tildes = "\\~{2}";
        // 2 tildes at the start of a string
        const rleading = /^(~{2})/;
        // 2 tildes at the end of a string
        const rtrailing = /(~{2}$)/;
        // 0-1 spaces at the end of a string
        // const rtrailingspace = /(\s?)$/;
        // 2+ line breaks
        const rnewlines = /\n{2,}/g;
        // the text to add when no text is selected
        const placeholder = "strikethrough text";

        // Remove leading & trailing white space from selection
        // (but do not remove from the user's text)
        chunks.trim();
        // Replace 2+ consecutive line breaks with 1 linebreak, otherwise
        // strikethrough syntax is incorrect and won't render HTML as expected
        chunks.selection = chunks.selection.replace(rnewlines, "\n");

        // See if the text before or after already contains ~~ at the start/end
        const leadTildes = rtrailing.exec(chunks.before);
        const trailTildes = rleading.exec(chunks.after);
        // See if the selected text already contains ~~ at start or end
        const selectLeadTildes = rleading.exec(chunks.selection);
        const selectTrailTildes = rtrailing.exec(chunks.selection);

        // If the selection is already surrounded by ~~, remove them
        if (leadTildes && trailTildes) {
          chunks.before = chunks.before.replace(rtrailing, "");
          chunks.after = chunks.after.replace(rleading, "");
          // If the selection starts & ends with ~~, remove them
        } else if (selectLeadTildes && selectTrailTildes) {
          chunks.selection = chunks.selection.replace(rleading, "");
          chunks.selection = chunks.selection.replace(rtrailing, "");
          // Otherwise, add a set of ~~
        } else {
          chunks.before += markup;
          chunks.after = markup + chunks.after;
          // Add the placeholder text if there was no selection
          if (chunks.selection.length <= 0) {
            chunks.selection = placeholder;
          }
        }
      },

      /**
       * updateMarkdown - Update the markdown attribute in this view using the
       * value of the markdown textarea
       */
      updateMarkdown() {
        const newMarkdown = this.$(this.textarea).val();
        // The markdown attribute in the model may be a string or an array of
        // strings. Although EML211 can comprise an array of markdown elements,
        // this view will only edit the first if there are multiple.
        const currentMarkdown = this.model.get("markdown");

        if (Array.isArray(currentMarkdown) && currentMarkdown.length) {
          // Clone then update arary before setting it on the model so that the
          // backbone "change" event is fired. See
          // https://stackoverflow.com/a/10240697
          const newMarkdownArray = _.clone(currentMarkdown);
          newMarkdownArray[0] = newMarkdown;
          this.model.set("markdown", newMarkdownArray);
        } else {
          this.model.set("markdown", newMarkdown);
        }
      },

      /**
       * previewMarkdown - render the markdown preview.
       */
      previewMarkdown() {
        let markdown = this.model.get("markdown");
        if (Array.isArray(markdown)) {
          if (markdown.length === 0) {
            markdown = this.markdownPlaceholder;
          } else {
            [markdown] = markdown;
          }
        }

        const markdownPreview = new MarkdownView({
          markdown: markdown || this.previewPlaceholder,
          showTOC: this.showTOC || false,
        });

        // Render the preview
        markdownPreview.render();
        // Add the rendered markdown to the preview tab
        this.$(`#markdown-preview-${this.cid}`).html(markdownPreview.el);
      },
    },
  );

  return MarkdownEditorView;
});