tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

multiline-editor.mjs (13194B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { html } from "chrome://global/content/vendor/lit.all.mjs";
      6 import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
      7 import {
      8  Decoration,
      9  DecorationSet,
     10  EditorState,
     11  EditorView,
     12  Plugin as PmPlugin,
     13  TextSelection,
     14  baseKeymap,
     15  basicSchema,
     16  history as historyPlugin,
     17  keymap,
     18  redo as historyRedo,
     19  undo as historyUndo,
     20 } from "chrome://browser/content/multilineeditor/prosemirror.bundle.mjs";
     21 
     22 /**
     23 * @class MultilineEditor
     24 *
     25 * A ProseMirror-based multiline editor.
     26 *
     27 * @property {string} placeholder - Placeholder text for the editor.
     28 * @property {boolean} readOnly - Whether the editor is read-only.
     29 */
     30 export class MultilineEditor extends MozLitElement {
     31  static shadowRootOptions = {
     32    ...MozLitElement.shadowRootOptions,
     33    delegatesFocus: true,
     34  };
     35 
     36  static properties = {
     37    placeholder: { type: String, reflect: true, fluent: true },
     38    readOnly: { type: Boolean, reflect: true, attribute: "readonly" },
     39  };
     40 
     41  static schema = basicSchema;
     42 
     43  #pendingValue = "";
     44  #placeholderPlugin;
     45  #plugins;
     46  #suppressInputEvent = false;
     47  #view;
     48 
     49  constructor() {
     50    super();
     51 
     52    this.placeholder = "";
     53    this.readOnly = false;
     54    this.#placeholderPlugin = this.#createPlaceholderPlugin();
     55    const plugins = [
     56      historyPlugin(),
     57      keymap({
     58        Enter: () => true,
     59        "Shift-Enter": (state, dispatch) =>
     60          this.#insertParagraph(state, dispatch),
     61        "Mod-z": historyUndo,
     62        "Mod-y": historyRedo,
     63        "Shift-Mod-z": historyRedo,
     64      }),
     65      keymap(baseKeymap),
     66      this.#placeholderPlugin,
     67    ];
     68 
     69    if (document.contentType === "application/xhtml+xml") {
     70      plugins.push(this.#createCleanupOrphanedBreaksPlugin());
     71    }
     72 
     73    this.#plugins = plugins;
     74  }
     75 
     76  /**
     77   * Whether the editor is composing.
     78   *
     79   * @type {boolean}
     80   */
     81  get composing() {
     82    return this.#view?.composing ?? false;
     83  }
     84 
     85  /**
     86   * The current text content of the editor.
     87   *
     88   * @type {string}
     89   */
     90  get value() {
     91    if (!this.#view) {
     92      return this.#pendingValue;
     93    }
     94    return this.#view.state.doc.textBetween(
     95      0,
     96      this.#view.state.doc.content.size,
     97      "\n",
     98      "\n"
     99    );
    100  }
    101 
    102  /**
    103   * Set the text content of the editor.
    104   *
    105   * @param {string} val
    106   */
    107  set value(val) {
    108    if (!this.#view) {
    109      this.#pendingValue = val;
    110      return;
    111    }
    112 
    113    if (val === this.value) {
    114      return;
    115    }
    116 
    117    const state = this.#view.state;
    118    const schema = state.schema;
    119    const lines = val.split("\n");
    120    const paragraphs = lines.map(line => {
    121      const content = line ? [schema.text(line)] : [];
    122      return schema.node("paragraph", null, content);
    123    });
    124    const doc = schema.node("doc", null, paragraphs);
    125 
    126    const tr = state.tr.replaceWith(0, state.doc.content.size, doc.content);
    127    tr.setMeta("addToHistory", false);
    128 
    129    const cursorPos = this.#posFromTextOffset(val.length, tr.doc);
    130    // Suppress input events when updating only the text selection.
    131    this.#suppressInputEvent = true;
    132    try {
    133      this.#view.dispatch(
    134        tr.setSelection(
    135          TextSelection.between(
    136            tr.doc.resolve(cursorPos),
    137            tr.doc.resolve(cursorPos)
    138          )
    139        )
    140      );
    141    } finally {
    142      this.#suppressInputEvent = false;
    143    }
    144  }
    145 
    146  /**
    147   * The start offset of the selection.
    148   *
    149   * @type {number}
    150   */
    151  get selectionStart() {
    152    if (!this.#view) {
    153      return 0;
    154    }
    155    return this.#textOffsetFromPos(this.#view.state.selection.from);
    156  }
    157 
    158  /**
    159   * Set the start offset of the selection.
    160   *
    161   * @param {number} val
    162   */
    163  set selectionStart(val) {
    164    this.setSelectionRange(val, this.selectionEnd ?? val);
    165  }
    166 
    167  /**
    168   * The end offset of the selection.
    169   *
    170   * @type {number}
    171   */
    172  get selectionEnd() {
    173    if (!this.#view) {
    174      return 0;
    175    }
    176    return this.#textOffsetFromPos(this.#view.state.selection.to);
    177  }
    178 
    179  /**
    180   * Set the end offset of the selection.
    181   *
    182   * @param {number} val
    183   */
    184  set selectionEnd(val) {
    185    this.setSelectionRange(this.selectionStart ?? 0, val);
    186  }
    187 
    188  /**
    189   * Set the selection range in the editor.
    190   *
    191   * @param {number} start
    192   * @param {number} end
    193   */
    194  setSelectionRange(start, end) {
    195    if (!this.#view) {
    196      return;
    197    }
    198 
    199    const doc = this.#view.state.doc;
    200    const docSize = doc.content.size;
    201    const maxOffset = this.#textLength(doc);
    202    const fromOffset = Math.max(0, Math.min(start ?? 0, maxOffset));
    203    const toOffset = Math.max(0, Math.min(end ?? fromOffset, maxOffset));
    204    const from = Math.max(
    205      0,
    206      Math.min(this.#posFromTextOffset(fromOffset, doc), docSize)
    207    );
    208    const to = Math.max(
    209      0,
    210      Math.min(this.#posFromTextOffset(toOffset, doc), docSize)
    211    );
    212 
    213    if (
    214      this.#view.state.selection.from === from &&
    215      this.#view.state.selection.to === to
    216    ) {
    217      return;
    218    }
    219 
    220    let selection;
    221    try {
    222      selection = TextSelection.between(doc.resolve(from), doc.resolve(to));
    223    } catch (_e) {
    224      const anchor = Math.max(0, Math.min(to, docSize));
    225      selection = TextSelection.near(doc.resolve(anchor));
    226    }
    227    this.#view.dispatch(
    228      this.#view.state.tr.setSelection(selection).scrollIntoView()
    229    );
    230    this.#dispatchSelectionChange();
    231  }
    232 
    233  /**
    234   * Select all text in the editor.
    235   */
    236  select() {
    237    this.setSelectionRange(0, this.value.length);
    238  }
    239 
    240  /**
    241   * Focus the editor.
    242   */
    243  focus() {
    244    this.#view?.focus();
    245    super.focus();
    246  }
    247 
    248  /**
    249   * Called when the element is added to the DOM.
    250   */
    251  connectedCallback() {
    252    super.connectedCallback();
    253    this.setAttribute("role", "presentation");
    254  }
    255 
    256  /**
    257   * Called when the element is removed from the DOM.
    258   */
    259  disconnectedCallback() {
    260    this.#destroyView();
    261    this.#pendingValue = "";
    262    super.disconnectedCallback();
    263  }
    264 
    265  /**
    266   * Called after the element’s DOM has been rendered for the first time.
    267   */
    268  firstUpdated() {
    269    this.#createView();
    270  }
    271 
    272  /**
    273   * Called when the element’s properties are updated.
    274   *
    275   * @param {Map} changedProps
    276   */
    277  updated(changedProps) {
    278    if (changedProps.has("placeholder") || changedProps.has("readOnly")) {
    279      this.#refreshView();
    280    }
    281  }
    282 
    283  #createView() {
    284    const mount = this.renderRoot.querySelector(".multiline-editor");
    285    if (!mount) {
    286      return;
    287    }
    288 
    289    const state = EditorState.create({
    290      schema: MultilineEditor.schema,
    291      plugins: this.#plugins,
    292    });
    293 
    294    this.#view = new EditorView(mount, {
    295      state,
    296      attributes: this.#viewAttributes(),
    297      editable: () => !this.readOnly,
    298      dispatchTransaction: this.#dispatchTransaction,
    299    });
    300 
    301    if (this.#pendingValue) {
    302      this.value = this.#pendingValue;
    303      this.#pendingValue = "";
    304    }
    305  }
    306 
    307  #destroyView() {
    308    this.#view?.destroy();
    309    this.#view = null;
    310  }
    311 
    312  #dispatchTransaction = tr => {
    313    if (!this.#view) {
    314      return;
    315    }
    316 
    317    const prevText = this.value;
    318    const prevSelection = this.#view.state.selection;
    319    const nextState = this.#view.state.apply(tr);
    320    this.#view.updateState(nextState);
    321 
    322    const selectionChanged =
    323      tr.selectionSet &&
    324      (prevSelection.from !== nextState.selection.from ||
    325        prevSelection.to !== nextState.selection.to);
    326 
    327    if (selectionChanged) {
    328      this.#dispatchSelectionChange();
    329    }
    330 
    331    if (tr.docChanged && !this.#suppressInputEvent) {
    332      const nextText = this.value;
    333      let insertedText = "";
    334      for (const step of tr.steps) {
    335        insertedText += step.slice?.content?.textBetween(
    336          0,
    337          step.slice.content.size,
    338          "",
    339          ""
    340        );
    341      }
    342      this.dispatchEvent(
    343        new InputEvent("input", {
    344          bubbles: true,
    345          composed: true,
    346          data: insertedText || null,
    347          inputType:
    348            insertedText || nextText.length >= prevText.length
    349              ? "insertText"
    350              : "deleteContentBackward",
    351        })
    352      );
    353    }
    354  };
    355 
    356  #dispatchSelectionChange() {
    357    this.dispatchEvent(
    358      new Event("selectionchange", { bubbles: true, composed: true })
    359    );
    360  }
    361 
    362  #insertParagraph(state, dispatch) {
    363    const paragraph = state.schema.nodes.paragraph;
    364    if (!paragraph) {
    365      return false;
    366    }
    367    const { $from } = state.selection;
    368    let tr = state.tr;
    369    if (!state.selection.empty) {
    370      tr = tr.deleteSelection();
    371    }
    372    tr = tr.split(tr.mapping.map($from.pos)).scrollIntoView();
    373    dispatch(tr);
    374    return true;
    375  }
    376 
    377  /**
    378   * Creates a plugin that shows a placeholder when the editor is empty.
    379   *
    380   * @returns {PmPlugin}
    381   */
    382  #createPlaceholderPlugin() {
    383    return new PmPlugin({
    384      props: {
    385        decorations: ({ doc }) => {
    386          if (
    387            doc.childCount !== 1 ||
    388            !doc.firstChild.isTextblock ||
    389            doc.firstChild.content.size !== 0 ||
    390            !this.placeholder
    391          ) {
    392            return null;
    393          }
    394 
    395          return DecorationSet.create(doc, [
    396            Decoration.node(0, doc.firstChild.nodeSize, {
    397              class: "placeholder",
    398              "data-placeholder": this.placeholder,
    399            }),
    400          ]);
    401        },
    402      },
    403    });
    404  }
    405 
    406  /**
    407   * Creates a plugin that removes orphaned hard breaks from empty paragraphs.
    408   *
    409   * In XHTML contexts the trailing break element in paragraphs are rendered as
    410   * uppercase (<BR> instead of <br>). ProseMirror seems to have issues parsing
    411   * these breaks, which leads to orphaned breaks after deleting text content.
    412   *
    413   * @returns {PmPlugin}
    414   */
    415  #createCleanupOrphanedBreaksPlugin() {
    416    return new PmPlugin({
    417      appendTransaction(transactions, prevState, nextState) {
    418        if (!transactions.some(tr => tr.docChanged)) {
    419          return null;
    420        }
    421 
    422        const tr = nextState.tr;
    423        let modified = false;
    424 
    425        nextState.doc.descendants((nextNode, nextPos) => {
    426          if (
    427            nextNode.type.name !== "paragraph" ||
    428            nextNode.textContent ||
    429            nextNode.childCount === 0
    430          ) {
    431            return true;
    432          }
    433 
    434          for (let i = 0; i < nextNode.childCount; i++) {
    435            if (nextNode.child(i).type.name === "hard_break") {
    436              const prevNode = prevState.doc.nodeAt(nextPos);
    437              if (prevNode?.type.name === "paragraph" && prevNode.textContent) {
    438                tr.replaceWith(
    439                  nextPos + 1,
    440                  nextPos + nextNode.content.size + 1,
    441                  []
    442                );
    443                modified = true;
    444              }
    445              break;
    446            }
    447          }
    448 
    449          return true;
    450        });
    451 
    452        return modified ? tr : null;
    453      },
    454    });
    455  }
    456 
    457  #refreshView() {
    458    if (!this.#view) {
    459      return;
    460    }
    461 
    462    this.#view.setProps({
    463      attributes: this.#viewAttributes(),
    464      editable: () => !this.readOnly,
    465    });
    466    this.#view.dispatch(this.#view.state.tr);
    467  }
    468 
    469  #textOffsetFromPos(pos, doc = this.#view?.state.doc) {
    470    if (!doc) {
    471      return 0;
    472    }
    473    return doc.textBetween(0, pos, "\n", "\n").length;
    474  }
    475 
    476  #posFromTextOffset(offset, doc = this.#view?.state.doc) {
    477    if (!doc) {
    478      return 0;
    479    }
    480    const target = Math.max(0, Math.min(offset ?? 0, this.#textLength(doc)));
    481    let seen = 0;
    482    let pos = doc.content.size;
    483    let found = false;
    484    let paragraphCount = 0;
    485    doc.descendants((node, nodePos) => {
    486      if (found) {
    487        return false;
    488      }
    489      if (node.type.name === "paragraph") {
    490        if (paragraphCount > 0) {
    491          if (target <= seen + 1) {
    492            pos = nodePos;
    493            found = true;
    494            return false;
    495          }
    496          seen += 1;
    497        }
    498        paragraphCount++;
    499      }
    500      if (node.isText) {
    501        const textNodeLength = node.text.length;
    502        const start = nodePos;
    503        if (target <= seen + textNodeLength) {
    504          pos = start + (target - seen);
    505          found = true;
    506          return false;
    507        }
    508        seen += textNodeLength;
    509      } else if (node.type.name === "hard_break") {
    510        if (target <= seen + 1) {
    511          pos = nodePos;
    512          found = true;
    513          return false;
    514        }
    515        seen += 1;
    516      }
    517      return true;
    518    });
    519    return pos;
    520  }
    521 
    522  #textLength(doc) {
    523    if (!doc) {
    524      return 0;
    525    }
    526    return doc.textBetween(0, doc.content.size, "\n", "\n").length;
    527  }
    528 
    529  #viewAttributes() {
    530    return {
    531      "aria-label": this.placeholder,
    532      "aria-multiline": "true",
    533      "aria-readonly": this.readOnly ? "true" : "false",
    534      role: "textbox",
    535    };
    536  }
    537 
    538  render() {
    539    return html`
    540      <link
    541        rel="stylesheet"
    542        href="chrome://browser/content/multilineeditor/prosemirror.css"
    543      />
    544      <link
    545        rel="stylesheet"
    546        href="chrome://browser/content/multilineeditor/multiline-editor.css"
    547      />
    548      <div class="multiline-editor"></div>
    549    `;
    550  }
    551 }
    552 
    553 customElements.define("moz-multiline-editor", MultilineEditor);