vue, Coding

Highlighting hashtags in textarea using Vue.js instead of Draft.js from Facebook

Background

Highlighting hashtags while writing something in the text field is required in my on going project. The text field made by Facebook is one of familiar functions you must see everyday. In addition, my custom text field needs to display predictive words from hashtags dynamically. This post is my research to accomplish to create library for highlighting hashtag using Vue.js instead of the hashtag plugin of Draft.js. The image below is of example by my library vue-hashtag-textarea.

Draft.js

At first I had a look of the html structure of Facebook on post page. it had “span” tag with “contentstate attribute. This attribute seems to decorate hashtags with highlight respectively if the sentence have at least a hashtag.

<span 
  contentstate="c { 
        &quot;entityMap": [object Object],
        "blockMap": OrderedMap { 
            "c9rd9": c { 
                "key": "c9rd9", 
                "type": "unstyled", 
                "text": "#aa", 
                "characterList": 
                List [ b { 
                        "style": OrderedSet {}, 
                        "entity": null }, 
                        b { 
                            "style": OrderedSet {}, 
                            "entity": null }, 
                            b { 
                                "style": OrderedSet {}, 
                                "entity": null } 
                    ], 
                "depth": 0, 
                "data": Map {} 
            } 
        }, 
        "selectionBefore": b { 
                                "anchorKey": "c9rd9", 
                                "anchorOffset": 0, 
                                "focusKey": "c9rd9", 
                                "focusOffset": 0, 
                                "isBackward": false, 
                                "hasFocus": true 
                            },
        "selectionAfter": b { 
                                "anchorKey": "c9rd9",
                                "anchorOffset": 3, 
                                "focusKey": "c9rd9", 
                                "focusOffset": 3, 
                                "isBackward": false, 
                                "hasFocus": true 
                            }}" 
  decoratedtext="#aa" 
  start="0" 
  end="3" 
  blockkey="c9rd9" 
  offsetkey="c9rd9-0-0" 
  data-offset-key="c9rd9-0-0" 
  class="_5zk7" 
  spellcheck="false">
  <span data-offset-key="c9rd9-0-0">
    <span data-text="true">#aa</span>
  </span>
</span>

I found it that this “contentstate attribute is one of the function by Draft.js and added on rendering. Unfortunately, this library is only available for Rect framework because of consisting of React components and my project adopts Vue framework for development. I decided, therefore, to start to create similar library instead of the hashtag plugin of the Draft.js.

Research

contenteditable

The following article helped me to understand the structure of Draft.js.

Draft.js · Rich Text Editor Framework for React
Draft is designed to solve problems for straightforward rich text interfaces

Summarizing the article, hashtags are extracted by regular expression from inputed text in the first scope, then callback is returned with the hashtags in it. After the process the hashtag words are replaced with React component for decoration…I think. To be honest I don’t know well about React. What is of importance in the process is ‘contenteditable’ which is basically equipped in modern browser. This html attribute makes an element editable. Although I used textarea property before implementing contenteditable element, I gave up using it because resizing process was gradually getting complicate. I realized that ‘contenteditableresized automatically container height if the container was set by css with 100% of height.

MutationObserver

When I used the contenteditable, I had to watch the change in real time. Vue.js supports v-model for form elemets, which means I can track text content change every time users input any text via watch which is a feature of Vue.js. I implemented MutationObserver which allow to watch any changes on the target of DOM element instead.

// target should have contenteditable attribute
const target = document.getElementById('input-true-text');
const observer = new MutationObserver(this.onObserveElement);    
const config = { 
                childList: true, 
                characterData: true,
                characterDataOldValue: true,
                subtree: true
              };

observer.observe(target, config);

System

Replace innerHTML

I put a layer for displaying content under a contenteditable layer with same size and position. Every time text content is changed, it is inserted with the full text into displaying layer as innerHTML after enclosing hashtags by <i></i> tag to decorate them by css.

Escape html character

If the text content includes <or & , innerHTML causes sometimes broken layout. I also encountered the similar issue. So I replaced the characters to be escaped with string characters at first, then executed next process.

Safari browser

When a line break is occurred, browsers inserts line break code like ‘\n’ into the root of contenteditable element. I noticed, however, Safari browser seemed to have a different interpretation from the other browsers in that its count. I don’t know why safari browser inserts a couple of line break code when it is occurred on the contenteditable element. I had to trim line break code ‘\n’ in case users access on Safari browser.

    onObserveElement(mutations) {
      mutations.forEach((mutation) => {
        const type = mutation.type

        switch(type) {
      // NOTE: on change of any text input
          case 'characterData':
            this.replaceContent()
            break;
      
      // NOTE: Line break is occured
          case 'childList':
            this.replaceContent()
            break;

          default:
            break;        
        }
      })
    },
    replaceContent() {
      const target = document.getElementById('input-true-text');

      // NOTE: Escape html characters
      const content = this.escapeHtml(target.innerText)
      const contentHTML = target.textContent

      // NOTE: Trim line break code (except Safari browser)
      const spaceExp = /^\n\n/gm
      const content2 = content.replace(spaceExp, function(match) {
        return '\n'
      })

      // NOTE: Create new text content
      const srcContent = this.isSafariBrowser ? content : content2
      const self = this

      // NOTE: Enclose hashtag by <i> tag
      const replaceContent = srcContent.replace(this.regExp, function(match) {
        const idStr = ' id=' + self.getUniqueStr()
        const result = '<i ' + self.hashtagStyle + idStr + '>' + match + '</i>'
        return result
      })

      const insertNode = document.getElementById('input-overlay')
      insertNode.innerHTML = replaceContent
    },

Enable to select hashtag

My library ‘vue-hashtag-textarea’ supports a preview mode so that users select a hashtag. The reason why my library supports two modes, preview and edit mode, is that system cannot distinguish if the cursor is selected for editing. In the preview mode, it swaps displaying layer with contenteditable layer in oder not to edit DOM at all, then watch the selection on the displaying element.

  mounted() {    
    const overlayElm = document.getElementById('input-overlay')
    overlayElm.addEventListener("click", this.onSelectHashtag, false);
  },
  methods: {
   onSelectHashtag(e) {
      const target = e.target
      const tagName = target.tagName

      if (tagName === 'I') {
        const content = target.textContent
        this.$emit('onSelectHashtag', target)
      }
    },
  }

Enable to replace hashtag

vue-hashtag-textarea also supports to replace currently inputting hashtag with new one such a predictive word from it.

Conclusion

I was also inspired from ScrapBox on the way to my library. The structure is a bit different from Draft.js. Respective words are inserted with span tag, although it seems to be implemented using contenteditable. This contenteditable is difficult to treat. My library might involve vulnerability somewhere because of the contenteditable.

vue-hashtag-textarea
Highlight hashtags on textarea with vue.js