Source: editor-menu.js

/* global $, libreviews, config */
const {
  wrapItem,
  blockTypeItem,
  Dropdown,
  DropdownSubmenu,
  joinUpItem,
  liftItem,
  undoItem,
  redoItem,
  icons,
  MenuItem
} = require("prosemirror-menu");

const unescapeHTML = require('unescape-html');

// Load proper translations for built-in items
undoItem.spec.title = libreviews.msg('undo');
redoItem.spec.title = libreviews.msg('redo');
joinUpItem.spec.title = libreviews.msg('join with item above');
liftItem.spec.title = libreviews.msg('decrease item indentation');

const { NodeSelection, TextSelection } = require('prosemirror-state');
const { toggleMark, wrapIn } = require('prosemirror-commands');
const { wrapInList } = require('prosemirror-schema-list');
const { TextField, openPrompt } = require('./editor-prompt');
const { uploadModal } = require('./upload-modal');
const { guessMediaType } = require('markdown-it-html5-media');

// Helpers to create specific types of items

function canInsert(state, nodeType, attrs) {
  let $from = state.selection.$from;
  for (let d = $from.depth; d >= 0; d--) {
    let index = $from.index(d);
    if ($from.node(d).canReplaceWith(index, index, nodeType, attrs))
      return true;
  }
  return false;
}

function insertMediaItem(nodeTypes, schema) {
  return new MenuItem({
    title: libreviews.msg('insert media help'),
    label: libreviews.msg('insert media'),
    select(state) {
      return canInsert(state, nodeTypes.image);
    },
    run(state, _dispatch, view) {
      let { from, to } = state.selection,
        attrs = null,
        showCaptionField = true;

      // Extract attributes from any media selection. We apply ALT text from
      // images to video/audio descriptions and vice versa
      if (state.selection instanceof NodeSelection) {
        switch (state.selection.node.type.name) {
          case 'image':
            attrs = {
              src: state.selection.node.attrs.src,
              alt: state.selection.node.attrs.alt ||
                state.selection.node.attrs.description || null
            };
            break;
          case 'video':
          case 'audio':
            attrs = {
              src: state.selection.node.attrs.src,
              description: state.selection.node.attrs.description ||
                state.selection.node.attrs.alt || null
            };
            break;
          default:
            // No default
        }
        showCaptionField = false;
      }
      const fields = {};

      fields.src = new TextField({ label: libreviews.msg('media url'), required: true, value: attrs && attrs.src });

      if (showCaptionField)
        fields.caption = new TextField({
          label: libreviews.msg('caption label'),
        });

      fields.alt = new TextField({
        label: libreviews.msg('media alt text'),
        value: attrs ? attrs.alt || attrs.description : state.doc.textBetween(from, to, " ")
      });

      openPrompt({
        view,
        fields,
        title: libreviews.msg('insert media dialog title'),
        callback(attrs) {
          const nodeType = guessMediaType(attrs.src);
          // <video>/<audio> tags do not support ALT; the text is rendered
          // as inner HTML alongside the fallback message.
          if (['video', 'audio'].includes(nodeType)) {
            attrs.description = attrs.alt;
            Reflect.deleteProperty(attrs, 'alt');
          }
          let tr = view.state.tr.replaceSelectionWith(nodeTypes[nodeType].createAndFill(attrs));
          if (attrs.caption && attrs.caption.length)
            tr = addCaption({ description: attrs.caption + '\n', schema, state: view.state, transaction: tr });
          view.dispatch(tr);
          view.focus();
        }
      });
    }
  });
}

function addCaption({ description, schema, state, transaction }) {
  const br = schema.nodes.hard_break.create(),
    descriptionNode = schema.text(description,
    schema.marks.strong.create()),
    pos = state.selection.$anchor.pos;
  return transaction
  .insert(pos + 1, br)
  .insert(pos + 2, descriptionNode);
}

function horizontalRuleItem(hr) {

  return new MenuItem({
    title: libreviews.msg('insert horizontal rule help', { accessKey: '_' }),
    label: libreviews.msg('insert horizontal rule'),
    select(state) {
      return canInsert(state, hr);
    },
    run(state, dispatch) {
      dispatch(state.tr.replaceSelectionWith(hr.create()));
    }
  });
}


function cmdItem(cmd, options) {
  let passedOptions = {
    label: options.title,
    run: cmd,
    select(state) {
      return cmd(state);
    }
  };
  for (let prop in options)
    passedOptions[prop] = options[prop];
  return new MenuItem(passedOptions);
}

function markActive(state, type) {
  let { from, $from, to, empty } = state.selection;
  if (empty)
    return type.isInSet(state.storedMarks || $from.marks());
  else
    return state.doc.rangeHasMark(from, to, type);
}

function markItem(markType, options) {
  let passedOptions = {
    active(state) {
      return markActive(state, markType);
    }
  };
  for (let prop in options)
    passedOptions[prop] = options[prop];
  return cmdItem(toggleMark(markType), passedOptions);
}

function fullScreenItem() {
  return new MenuItem({
    title: libreviews.msg('full screen mode', { accessKey: 'u' }),
    icon: { dom: $('<span class="fa fa-arrows-alt baselined-icon"></span>')[0] },
    active() {
      return this.enabled || false;
    },
    run(state, _dispatch, view) {
      let $rteContainer = $(view.dom).closest('.rte-container');
      let id = Number($rteContainer[0].id.match(/\d+/)[0]);
      if (!this.enabled) {
        libreviews.activeRTEs[id].enterFullScreen();
        this.enabled = true;
      } else {
        libreviews.activeRTEs[id].exitFullScreen();
        this.enabled = false;
      }
      view.updateState(state);
    }
  });
}

function uploadModalItem(mediaNodes, schema) {
  return new MenuItem({
    title: libreviews.msg('upload and insert media'),
    icon: { dom: $('<span class="fa fa-cloud-upload baselined-icon"><span>')[0] },
    active() {
      return false;
    },
    run(state, dispatch, view) {
      // For some forms, we submit uploaded file IDs so they can be processed
      // server-side
      const $form = $(view.dom).closest('form[data-submit-uploaded-files]');
      uploadModal(uploads => {
        const upload = uploads[0];
        const attrs = {
          src: `/static/uploads/${encodeURIComponent(upload.uploadedFileName)}`
        };
        const nodeType = guessMediaType(attrs.src);
        const description = generateDescriptionFromUpload(upload);
        let tr = state.tr
          .replaceSelectionWith(mediaNodes[nodeType].createAndFill(attrs));
        tr = addCaption({ description, schema, state, transaction: tr });
        dispatch(tr);

        if ($form.length) {
          $form.append(`<input type="hidden" ` +
            ` name="uploaded-file-${upload.fileID}" value="1">`);
          if ($form.find('#social-media-image-select').length) {
            let summarizedDesc = upload.description[config.language].substr(0, 80);
            if (upload.description[config.language].length > 80)
              summarizedDesc += '...';
            $('#social-media-image-select').append(`<option value="${upload.fileID}">` +
            `${upload.uploadedFileName}: ${summarizedDesc}` +
            `</option>`);
          }
        }
        view.focus();
      });
    }
  });
}

function generateDescriptionFromUpload(upload) {
  // API returns escaped HTML; editor will re-escape it
  const description = unescapeHTML(upload.description[config.language]);
  const creator = upload.creator && upload.creator[config.language];
  let license;
  switch (upload.license) {
    case 'fair-use':
      license = libreviews.msg('fair use in caption');
      break;
    case 'cc-0':
      license = libreviews.msg('public domain in caption');
      break;
    default:
      license = libreviews.msg('license in caption', {
        stringParam: libreviews.msg(`${upload.license} short`)
      });
  }

  let rights;
  if (!creator) // Own work
    rights = libreviews.msg('rights in caption, own work', { stringParam: license });
  else
    rights = libreviews.msg('rights in caption, someone else\'s work', {
      stringParams: [creator, license]
    });
  const caption = libreviews.msg('caption', { stringParams: [description, rights] });
  // Final newline is important to ensure resulting markdown is parsed correctly
  return caption + '\n';
}

function formatCustomWarningItem(nodeType) {
  return new MenuItem({
    title: libreviews.msg('format as custom warning help'),
    label: libreviews.msg('format as custom warning'),
    run(state, dispatch, view) {
      let prompt = {
        view,
        title: libreviews.msg('format as custom warning dialog title'),
        fields: {
          message: new TextField({
            label: libreviews.msg('custom warning text'),
            required: true
          })
        },
        callback(attrs) {
          // Used to translate node back into markdown
          attrs.markup = `warning ${attrs.message}`;
          wrapIn(nodeType, attrs)(state, dispatch);
          view.focus();
        }
      };
      openPrompt(prompt);
    },
    select(state) {
      return wrapIn(nodeType)(state);
    }
  });
}

function linkItem(schema) {
  return new MenuItem({
    title: libreviews.msg('add or remove link', { accessKey: 'k' }),
    icon: icons.link,
    active(state) {
      return markActive(state, schema.marks.link);
    },
    run(state, dispatch, view) {
      if (markActive(state, schema.marks.link)) {
        toggleMark(schema.marks.link)(state, dispatch);
        return true;
      }
      const required = true;
      const fields = {
        href: new TextField({
          label: libreviews.msg('web address'),
          required,
          clean: val => !/^https?:\/\//i.test(val) ? 'http://' + val : val
        })
      };
      // User has not selected any text, so needs to provide it via dialog
      if (view.state.selection.empty)
        fields.linkText = new TextField({
          label: libreviews.msg('link text'),
          required,
          clean: val => val.trim()
        });
      openPrompt({
        view,
        title: libreviews.msg('add link dialog title'),
        fields,
        callback(attrs) {
          if (!attrs.linkText) {
            // Transform selected text into link
            toggleMark(schema.marks.link, attrs)(view.state, view.dispatch);
            // Advance cursor to end of selection (not necessarily head,
            // depending on selection direction)
            let rightmost = view.state.selection.$anchor.pos > view.state.selection.$head.pos ?
              view.state.selection.$anchor : view.state.selection.$head;
            view.dispatch(view.state.tr.setSelection(TextSelection.between(rightmost, rightmost)));
            // Disable link mark so user can now type normally again
            toggleMark(schema.marks.link, attrs)(view.state, view.dispatch);
          } else {
            view.dispatch(
              view.state.tr
              .replaceSelectionWith(schema.text(attrs.linkText))
              .addMark(view.state.selection.$from.pos,
                view.state.selection.$from.pos + attrs.linkText.length,
                schema.marks.link.create({ href: attrs.href }))
            );
          }
          view.focus();
        }
      });
    }
  });
}

function wrapListItem(nodeType, options) {
  return cmdItem(wrapInList(nodeType, options.attrs), options);
}

function headingItems(nodeType) {
  const headingItems = [];
  for (let i = 1; i <= 6; i++)
    headingItems[i - 1] = blockTypeItem(nodeType, {
      title: libreviews.msg('format as level heading help', { accessKey: String(i), numberParam: i }),
      label: libreviews.msg('format as level heading', { numberParam: i }),
      attrs: { level: i }
    });
  return headingItems;
}

/**
 * Build a menu for nodes and marks supported in the markdown schema.
 *
 * @param {Schema} schema
 *  the markdown schema
 * @returns {Object}
 *  the generated menu and all its items
 */
function buildMenuItems(schema) {
  const mediaNodes = {
    image: schema.nodes.image,
    video: schema.nodes.video,
    audio: schema.nodes.audio
  };

  const items = {
    toggleStrong: markItem(schema.marks.strong, { title: libreviews.msg('toggle bold', { accessKey: 'b' }), icon: icons.strong }),
    toggleEm: markItem(schema.marks.em, { title: libreviews.msg('toggle italic', { accessKey: 'i' }), icon: icons.em }),
    toggleCode: markItem(schema.marks.code, { title: libreviews.msg('toggle code', { accessKey: '`' }), icon: icons.code }),
    toggleLink: linkItem(schema),
    insertMedia: insertMediaItem(mediaNodes, schema),
    insertHorizontalRule: horizontalRuleItem(schema.nodes.horizontal_rule),
    wrapBulletList: wrapListItem(schema.nodes.bullet_list, {
      title: libreviews.msg('format as bullet list', { accessKey: '8' }),
      icon: icons.bulletList
    }),
    wrapOrderedList: wrapListItem(schema.nodes.ordered_list, {
      title: libreviews.msg('format as numbered list', { accessKey: '9' }),
      icon: icons.orderedList
    }),
    wrapBlockQuote: wrapItem(schema.nodes.blockquote, {
      title: libreviews.msg('format as quote', { accessKey: '>' }),
      icon: icons.blockquote
    }),
    makeParagraph: blockTypeItem(schema.nodes.paragraph, {
      title: libreviews.msg('format as paragraph help', { accessKey: '0' }),
      label: libreviews.msg('format as paragraph')
    }),
    makeCodeBlock: blockTypeItem(schema.nodes.code_block, {
      title: libreviews.msg('format as code block help'),
      label: libreviews.msg('format as code block')
    }),
    formatSpoilerWarning: wrapItem(schema.nodes.container_warning, {
      title: libreviews.msg('format as spoiler help'),
      label: libreviews.msg('format as spoiler'),
      attrs: { markup: 'spoiler', message: libreviews.msg('spoiler warning') }
    }),
    formatNSFWWarning: wrapItem(schema.nodes.container_warning, {
      title: libreviews.msg('format as nsfw help'),
      label: libreviews.msg('format as nsfw'),
      attrs: { markup: 'nsfw', message: libreviews.msg('nsfw warning') }
    }),
    formatCustomWarning: formatCustomWarningItem(schema.nodes.container_warning),
    makeHeading: headingItems(schema.nodes.heading),
    fullScreen: fullScreenItem(),
    undo: undoItem,
    redo: redoItem,
    joinUp: joinUpItem,
    lift: liftItem
  };

  // Only trusted users can upload files directly from within the RTE.
  if (config.isTrusted)
    items.upload = uploadModalItem(mediaNodes, schema);

  const insertDropdown = new Dropdown([items.insertMedia, items.insertHorizontalRule], {
    label: libreviews.msg('insert'),
    title: libreviews.msg('insert help')
  });

  const headingSubmenu = new DropdownSubmenu([...items.makeHeading], { label: libreviews.msg('format as heading') });

  const typeDropdown = new Dropdown(
    [
      items.makeParagraph, items.makeCodeBlock, items.formatSpoilerWarning,
      items.formatNSFWWarning, items.formatCustomWarning, headingSubmenu
    ], {
      label: libreviews.msg('format block'),
      title: libreviews.msg('format block help')
    });

  // Create final menu structure. In the rendered menu, there is a separator
  // symbol between each array

  const mediaOptions = [insertDropdown];

  // Only trusted users can upload files directly via the RTE.
  if (config.isTrusted)
    mediaOptions.push(items.upload);

  const menu = [
    [items.toggleStrong, items.toggleEm, items.toggleCode, items.toggleLink],
    mediaOptions,
    [typeDropdown, items.wrapBulletList, items.wrapOrderedList, items.wrapBlockQuote, items.joinUp, items.lift],
    [items.undo, items.redo],
    [items.fullScreen]
  ];

  // We expose the items object so it can be used to externally trigger a menu
  // function, e.g., via a keyboard shortcut
  return { menu, items };
}

exports.buildMenuItems = buildMenuItems;