anyone-oslo/pages

View on GitHub
app/javascript/components/RichTextArea.jsx

Summary

Maintainability
B
6 hrs
Test Coverage
import React from "react";
import PropTypes from "prop-types";
import RichTextToolbarButton from "./RichTextToolbarButton";

export default class RichTextArea extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: props.value || "",
      rows: props.rows || 5
    };
    this.inputRef = React.createRef();
    this.handleChange = this.handleChange.bind(this);
    this.handleKeyPress = this.handleKeyPress.bind(this);
    this.getSelection = this.getSelection.bind(this);
    this.link = this.link.bind(this);
    this.replaceSelection = this.replaceSelection.bind(this);
  }

  actions() {
    const simple = [
      {
        name: "bold",
        className: "bold",
        hotkey: "b",
        fn: (str) => ["<b>", str, "</b>"]
      },
      {
        name: "italic",
        className: "italic",
        hotkey: "i",
        fn: (str) => ["<i>", str, "</i>"]
      }
    ];

    const advanced = [
      {
        name: "Heading 2",
        className: "header h2",
        fn: (str) => ["h2. ", str, ""]
      },
      {
        name: "Heading 3",
        className: "header h3",
        fn: (str) => ["h3. ", str, ""]
      },
      {
        name: "Heading 4",
        className: "header h4",
        fn: (str) => ["h4. ", str, ""]
      },
      {
        name: "Blockquote",
        className: "quote-left",
        fn: (str) => ["bq. ", str, ""]
      },
      {
        name: "List",
        className: "list-ul",
        fn: (str) => ["", this.strToList(str, "*"), ""]
      },
      {
        name: "Ordered list",
        className: "list-ol",
        fn: (str) => ["", this.strToList(str, "#"), ""]
      },
      {
        name: "Link",
        className: "link",
        fn: this.link
      },
      {
        name: "Email link",
        className: "envelope",
        fn: this.emailLink
      }
    ];

    return this.props.simple ? simple : [...simple, ...advanced];
  }

  applyAction(fn) {
    let [prefix, replacement, postfix] = fn(this.getSelection());
    this.replaceSelection(prefix, replacement, postfix);
  }

  emailLink(selection) {
    var address = prompt("Enter email address", "");
    let name = selection.length > 0 ? selection : address;
    return ['"', name, `":mailto:${address}`];
  }

  getSelection() {
    let { selectionStart, selectionEnd, value } = this.inputRef.current;
    return value.substr(selectionStart, selectionEnd - selectionStart);
  }

  handleChange(evt) {
    this.setState({ value: evt.target.value });
  }

  handleKeyPress(evt) {
    let key;
    if (evt.which >= 65 && evt.which <= 90) {
      key = String.fromCharCode(evt.keyCode).toLowerCase();
    } else if (evt.keyCode === 13) {
      key = "enter";
    }

    let hotkeys = {};
    this.actions().forEach((a) => {
      if (a.hotkey) {
        hotkeys[a.hotkey] = a.fn;
      }
    });

    if (
      (evt.metaKey || evt.ctrlKey) &&
      Object.prototype.hasOwnProperty.call(hotkeys, key)
    ) {
      evt.preventDefault();
      this.applyAction(hotkeys[key]);
    }
  }

  link(selection) {
    let name = selection.length > 0 ? selection : "Link text";
    var url = prompt("Enter link URL", "");
    if (url) {
      return ['"', name, `":${this.relativeUrl(url)}`];
    } else {
      return ["", name, ""];
    }
  }

  localeOptions() {
    let opts = {};

    if (this.props.lang) {
      opts.lang = this.props.lang;
    }

    if (this.props.dir) {
      opts.dir = this.props.dir;
    }

    return opts;
  }

  relativeUrl(str) {
    let url = null;

    if (!str.match(/^https:\/\//) || !document || !document.location) {
      return str;
    }

    try {
      url = new URL(str);
    } catch (error) {
      console.log("Error parsing URL: ", error);
    }

    if (
      url &&
      url.hostname == document.location.hostname &&
      (document.location.port || "80") == (url.port || "80")
    ) {
      return url.pathname;
    }
    return str;
  }

  render() {
    let { value, rows } = this.state;
    let { id, name } = this.props;

    const clickHandler = (fn) => (evt) => {
      evt.preventDefault();
      this.applyAction(fn);
    };

    return (
      <div className="rich-text-area">
        <div className="rich-text toolbar">
          {this.actions().map((a) => (
            <RichTextToolbarButton
              key={a.name}
              name={a.name}
              className={a.className}
              onClick={clickHandler(a.fn)}
            />
          ))}
        </div>
        <textarea
          className="rich"
          ref={this.inputRef}
          id={id}
          name={name}
          value={value}
          rows={rows}
          onChange={this.handleChange}
          onKeyDown={this.handleKeyPress}
          {...this.localeOptions()}
        />
      </div>
    );
  }

  replaceSelection(prefix, replacement, postfix) {
    let textarea = this.inputRef.current;
    let { selectionStart, selectionEnd, value } = textarea;

    textarea.value =
      value.substr(0, selectionStart) +
      prefix +
      replacement +
      postfix +
      value.substr(selectionEnd, value.length);

    textarea.focus({ preventScroll: true });
    textarea.setSelectionRange(
      selectionStart + prefix.length,
      selectionStart + prefix.length + replacement.length
    );
    this.setState({ value: textarea.value });
  }

  strToList(str, prefix) {
    return str
      .split("\n")
      .map((l) => prefix + " " + l)
      .join("\n");
  }
}

RichTextArea.propTypes = {
  id: PropTypes.string,
  name: PropTypes.string,
  value: PropTypes.string,
  rows: PropTypes.number,
  simple: PropTypes.bool,
  lang: PropTypes.string,
  dir: PropTypes.string
};