Title party identity exposure, this article is mainly about how to achieve a lightweight editor in the Web, pure personal exploration process, if there are big god welcome to explore, light spray, the end of the article has the effect demonstration.

Why do you do it?

I work for a financial company, and the IT group is a tool for financial researchers to do their jobs and improve their performance. (After all, everyone in the Web is a tool guy.) The daily work of researchers used to be done on Excel, which was tedious and inefficient. Our task was to move this to the Web, so there was a need for “formula calculation”.

The Java brother at the back end supports a formula engine, which can parse the legal formula text input by users, extract data from the database and return it to the front end. For example, there are four operations:

idx(10009) - idx(10010)

idx(10009) * 0.01

idx(10009) / 100

idx(10009) ^ 2

(idx(10009) - idx(10010)) /100
Copy the code

This means to find the data whose ID is XXXX, and perform the operation, and some advanced operation formulas:

grow(idx(10009)) // represents the sequential growth rate sequence of the specified indicator sequence, that is, the current period divided by the previous period subtracted by 1
yoy(idx(10009)) // Get the year-on-year growth rate sequence of the indicator sequence, that is, divide the current period by the same period last year and subtract 1
Copy the code

Before the front end, it provided a Textarea for the user to input and did not verify the user input. In practice, when the user input a long formula, he was not sure whether it was completely consistent with the grammar, so he could not calculate, resulting in poor use experience.

And the idea of the leader happened to coincide:

It would be nice if we programmers had an intelligent hint when writing code

To start!

1. Obviously a rich text editor

Yes, to support user input text and intelligent coloring, the front end is to turn user input into an inline HTML text style. Considering extensibility, we are not considering any open source rich text editor here, so what does it take to handload a rich text editor?

  1. Accept user input, HTML naturally think of TextArea, natural support for input text, text can be long line feed.
  2. Textarea cannot handle rich inline styles, which must be a piece of HTML.

Based on the above two points, an idea quickly arose: a div and a Textarea would be completely superimposed, and the Textarea would take user input, go through a series of transformations to become an HTML paragraph, and be placed into the div with the innerHTML method to display a rich style. Add a color: transparent to the textarea CSS. Direct invisibility, solved. The cursor in a textarea may look like it is inserted into a word. The cursor in a textarea may look like it is inserted into a word. Font-family: ‘sfmono-regular ‘, Consolas, ‘Liberation Mono’, Menlo, Courier, Monospace; Set the font as equal width, so that the horizontal space of each word is the same, the subsequent implementation does not appear strange cursor problem.

The implementation found that textarea was not ideal because of some built-in default styles. , did not align well with divs, and finally a Contenteditable div was used to accept user input.

To make elements editable, all you have to do is set the “contenteditable” property on the HTML tag, which supports almost all HTML elements. Here is a simple example of creating a div element with a “contenteditable” attribute of “true” and allowing the user to edit its contents. — Quoted from MDN Web Doc

<div contenteditable="true">
  This text can be edited by the user.
</div>
Copy the code

The editable divs are called EditDivs below, and the ones responsible for presenting styles are called CSSDiv.

2. Associate the content changes of the two divs

This article contains some VUE3 code, but the core logic is framework independent.

Listen for the input event in the EditDiv, take the content, process it to include the style, and assign the value to the CSSDIV with innerHTML. Cssdiv does not need to listen for content changes, and accepts content passively.

3. Become beautiful

What exactly is the so-called “processing”? Function names denote functions, parentheses accept arguments, and if the text is highlighted like an IDE, if the user input is not syntactically correct, then the coloring exception is a problem. This is too complicated to write yourself, so Google code highlighting and there is little to answer except highlight.js (HLJS), which many online ides use for code highlighting. Based on lexical analysis, HLJS can achieve accurate code coloring according to different programming languages and provide an HTML text containing styles. In this article, JavaScript style is directly selected (in fact, most languages can be used).

// Listen for the editdiv input event
function formulaChange(e: InputEvent) {
    constel = formulaCodeRef.value! ;/ / this is cssdiv
    const text = (e.target asHTMLDivElement)! .innerText;// Get the editdiv text
    el.innerHTML = text; // Go directly to cssdiv
    hljs.highlightElement(el); // HLJS DOM jacks the CSSdiv and automatically renders the colorful text after processing its contents
    // At this point beautiful text has appeared
    setSuggestions(e); // Implement code intelligent prompt function
    nextTick(() = > {
      // Displays the current cursor position in real time
      emit('updateEndOffset'.window.getSelection()! .getRangeAt(0).endOffset);
    });
}
Copy the code

Here’s another piece of information, the HTML5 underdog DOM: Pre.

The pre element represents predefined formatted text. The text in this element is usually formatted as in the original file, in a typeface of equal width, and whitespace (such as Spaces and newlines) in the text is displayed. (The newline immediately after the

 start tag is also omitted) -- from the MDN Web Doc

The pre tag is a good way to represent formatted text:

<pre>
Text in a pre element
is displayed in a fixed-width
font, and it preserves
both      spaces and
line breaks
</pre>
Copy the code

Render it (no black background, of course)

Text in a pre element
is displayed in a fixed-width
font, and it preserves
both      spaces and
line breaks

Therefore, cSSDIV also uses the Pre tag, which is also required by HLJS, so the core DOM code is as follows:

<div class="relative overflow-visible">
    <div
      class="formula-editor focus:outline-blue-600 rounded-md"
      ref="formulaEditorRef"
      contenteditable
      @input="formulaChange"
      @blur="updateFormula"
    ></div>
    <! The text you can see in pre is language-javascript telling HLJS to use javascript-->
    <pre class="language-javascript code rounded-sm" ref="formulaCodeRef"></pre>
  </div>
Copy the code

CSS:

// This is the editdiv.formula-editor{// The editor has a black background and the cursor is caret- near whitecolor: #b8b8b8;
    position: absolute;
    overflow: hidden; // Support vertical length and shorteningresize: vertical;
    width: 100%;
    height: 7.5 rem;
    z-index: 9;
    padding: 4px 11px;
    font-size: 16px;
    line-height: 24px;
    color: transparent;
    white-space: wrap;
    word-wrap: break-word;
    word-break: break-all;
    font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; } // This is the pre tag.code {
    padding: 4px 11px;
    position: absolute; // Consecutive whitespace characters are preserved. Line breaks occur only when a newline character or <br> element is encounteredwhite-space: pre-wrap; // Force a line breakword-wrap: break-word; // For non-CJK (CJK refers to Chinese/Japanese/Korean) text, the line can be broken at any characterword-break: break-all;
    font-size: 16px;
    line-height: 24px;
    top: 0;
    text-align: left;
    width: 100%; // Pre is only for beauty, don't respond to any mouse events, very importantpointer-events: none;
  }
Copy the code

The main idea is to keep the contents of the two containers in the same format with some font size, line height, and newline rules so that the cursor doesn’t appear in strange places. The pre tags are naturally uniform in width. Use the z – index: 9; To make the EditDiv accept mouse and keyboard events at the top level. pointer-events: none; Let the Pre tag be a man in peace.

4. Guess the formula you want

Here comes the big smart tip. The first thing that comes to mind here is to prepare an array containing all the internal formulas for grow,sum, idx, etc., take the user’s input text, match the array, generate a list of suggestions, and the user uses Tab to select and enter to confirm. This list of suggestions should, of course, be attached to a DOM:

// Get the autocomplete word list DOM
  function setSuggestionsDOM(e: Ref<HTMLDivElement | undefined>) { suggestionsDOM.value = e.value! ; suggestionsDOMDisplay = getComputedStyle(e.value!) .getPropertyValue('display');
    suggestionsDOM.value.style.display = 'none';
  }
Copy the code

Here it is mounted when the node is rendered, but with display: None; I’m going to hide it and keep the original display, maybe flex, maybe block, whatever, and then I’m going to revert it back to the DOM.

The effect of the suggestion list is to take the character ‘s’ or ‘su’ and give the suggested text ‘sum’ and ‘sum_acc’, but note that the input event will also be triggered when the user selects the intelligent-prompted word. So you need an autoComplate(Boolean) to distinguish between typing by hand and auto-completion. In the input event, count backwards based on the current cursor position until a non-English character is matched, which is almost a broken word waiting to be auto-completed.

How do I get the list of suggestions to follow the cursor? I’m looking for the browser API, but it doesn’t exist. (if you know you can write in the comments section), you can calculate a reasonable position based on the cursor position and the word width and DOM width and height information, all thanks to the use of the same width font. This, of course, is calculated each time an input event is emitted.

function setSuggestions(e: InputEvent) {
    // DOM of the suggestion list
    constsugDomStlye = suggestionsDOM.value! .style;// If auto-complete mode is identified, return (otherwise infinite loop)
    if (autoComplate.value) {
      sugDomStlye.display = 'none';
      return;
    }
    // Get the current cursor position
    endOffset = window.getSelection()! .getRangeAt(0).endOffset;
    // Get the text
    const text = (e.target asHTMLDivElement)! .innerText;// regardless of parentheses and non-English characters, return
    if (['('.'['.'{'].includes(text.charAt(endOffset)) || /[^a-zA-Z]/i.test(e.data!) ) { sugDomStlye.display ='none';
      return;
    }
    // Get the substring to match
    let str = ' ';
    startOffset = endOffset - 1;
    // Count backwards from the cursor position to the beginning of the string or a non-alphabetic character
    while(startOffset ! = = -1 && /[a-zA-Z]/i.test(text.charAt(startOffset))) {
      str = `${text.charAt(startOffset)}${str}`;
      startOffset -= 1;
    }
    // Return if no string is typed by the user
    startOffset += 1;
    if (str.length === 0) {
      sugDomStlye.display = 'none';
      return;
    }
    // To match in the built-in formula list, simply use the string include method
    fnList.value = allFnToken.filter((s) = > s.includes(str.trim()));
    if (fnList.value.length > 0) {
      // Calculate the cursor position relative to the screen
      const el = e.target as HTMLDivElement;
      const { x, y, width } = el.getBoundingClientRect();
      const singleLineLen = parseInt(width / 8.8);
      const left = (endOffset % singleLineLen) * 8.8 + 14 + x;
      const top = (parseInt(endOffset / singleLineLen) + 1) * 24 + y;
      Object.assign(sugDomStlye, {
        left: `${left}px`.top: `${top}px`.display: suggestionsDOMDisplay,
      });
      show.value = true;
      window.addEventListener('keypress', keyboardListener);
    } else {
      sugDomStlye.display = 'none';
      show.value = false;
      window.removeEventListener('keypress', keyboardListener); }}Copy the code

When the suggestion list appears, it starts to listen to keyboard events. In keyboardListener, the user presses TAB to switch words in the suggestion list and press Enter to perform automatic completion. With regard to TAB switching, here’s something to know about the focus of browser keyboard switching, particularly the TABIndex attribute of HTML.

Tabindex indicates whether its element can be focused and whether/where it participates in sequential keyboard navigation (usually using the Tab key, hence the name). It accepts an integer as a value, with different results depending on the value of the integer — quoted in the MDN Web Doc

  • Tabindex = negative (usually tabIndex = “-1”), indicating that the element is focusable but cannot be queried by keyboard navigation, which is useful when using JS for internal keyboard navigation of page widgets.
  • tabindex="0", indicating that the element is focusable and can be focused to by keyboard navigation, and its relative order is determined by the DOM structure in which it is currently located.
  • Tabindex = positive, indicating that the element is focusable and can be queried by keyboard navigation; Its relative order increases with tabIndex and lags in focusing. If multiple elements have the same TabIndex, their relative order is determined by their order in the current DOM.

Depending on the order in which the keyboard sequence navigates, elements with a value of 0, invalid, or no tabIndex value should be placed after elements with a positive tabIndex value. So the key to the suggestion list is to add a tabIndex property:

<span
    v-for="item in fnList"
    :key="item"
    :data-suggestion="item"
    tabIndex="0"
    class="pl-1 focus:bg-primary focus:text-white leading-4 text-gray-500"
    >{{ item }}
</span>
Copy the code

Then you can press TAB to switch the current focus element to the suggestion list.

async function keyboardListener(e: KeyboardEvent) {
    // Get the current focus element, which is the word selected by the user by pressing TAB
    const activeElement = document.activeElement as HTMLElement;
    const str = activeElement.dataset.suggestion;
    // In response to the return key, be careful to intercept the default event, otherwise you will insert a return newline.
    if (str && e.key === 'Enter') {
      constdom = inputDOM.value! ;const text = dom.innerText;
      const replaceText = fnMap[str].value;
      const str1 = text.substring(0, startOffset);
      const str2 = text.substring(endOffset);
      dom.innerText = `${str1}${replaceText}${str2}`;
      // Trigger the input native event
      const evt = document.createEvent('HTMLEvents');
      evt.initEvent('input'.true.true);
      autoComplate.value = true;
      dom.dispatchEvent(evt);
      dom.focus();
      const focusDOM = window.getSelection()! .getRangeAt(0)! ;// Set the cursor position
      focusDOM.setStart(dom.childNodes[0], startOffset + fnMap[str].endOffset);
      focusDOM.collapse(true);
      // Prevents entering carriage return characters
      e.preventDefault();
      autoComplate.value = false; }}Copy the code

We’re going to simulate an input event with createEvent and dispatchEvent, and we’re going to add autocomplete text to the DOM, and you’re going to want to use innerText. Ok, no, don’t forget that we’re listening for the input event, so we’re going to synchronize the user input text to the Pre tag, So the input event is also triggered here. Finally, set the cursor position in a reasonable position. A parenthesis will be added automatically to complete the word here. It is more convenient for the cursor to appear in the middle of the parenthesis.

The browser doesn’t give you the concept of a cursor, but rather a “select object” operation, where you can pull text from a paragraph with the mouse, right-click to copy it, and so on. Focusdom.setstart represents the starting point for setting the selected object. Focusdom.collapse (true) specifies that the start and end points overlap, which appears to have changed the cursor position.

In my opinion, the implementation of a DOM hanging near the cursor is still too clumsy. If you have better ideas, welcome to discuss. Effect demonstration: