3

I need to implement a custom mentions in LWC component where external non-salesforce users can be mentioned, the mention text should be displayed as @{UserFirstName}{UserLastName} but the actual text sent to external server should have bare text user.externalGUID instead of the mention. Still in LWC this should be displayed as @{UserFirstName}{UserLastName}

I have performed some investigation and have found that mentioned are usually implemented through either a custom textarea processing or contenteditable div element.

Salesforce uses in the Ligtning Experience quill library to implement mentions.

I have found that there is react plugin for this, but I can't use it inside LWC.

Quill library has a powerful rich text editor capabilities which I don't need, the only thing I need is mentions.

I have been debugging Salesforce mentions implementation to get idea how custom mentions can be implemented, but I feel I got totally lost, the only thing I understood it that Salesforce uses Quill library for that.

Should I use textarea or content editable div for this?

Should I try to find some JS library which implements this and is LWC-complaint or try to build this from scratch?


What I have tried so far:

I tried to use content editable div and clear up the innerHTML property

event.target.innerHTML = '';

and the populate the list of child elements

<div class="ql-editor slds-rich-text-area__content slds-text-color_weak slds-grow" data-gramm="false" contenteditable="true" data-placeholder="Share an update..." aria-expanded="true" role="combobox" aria-autocomplete="list" onkeydown={keydown} onkeypress={keypress} onkeyup={keyup}>
<template for:each={children} for:item="child">
 <template if:true={child.isSpan}>
 <span key={child.index} contenteditable="false" class="ql-chatter-mention quill_widget_element" tabindex="-1" data-widget="chatterMention">{child.text}</span>
  </template>
 <template if:true={child.isText}>
  {child.text}
   </template>
 </template>
 </div>

However, when I tried to do this the second time, the previous three items no longer render despite there are five elements in the array list.

   this.children.push( {index:0,isText:true,text:event.target.innerText.substr(0, this.startPos - 1)});
   this.children.push( {index:1,isSpan:true,text:'@[' + user.first_name + ' ' + user.last_name + ']'});
   this.children.push( {index:2,isText:true,text:event.target.innerText.substr(pos)});

I have even tried to reassign the array property

this.children = JSON.parse(JSON.stringify(this.children));

but this didn't help

1

After two weeks of investigation and exploring all the possible options, I decided to use Quill library (which Salesforce also uses for its internal mention implementation) and upload it into the Static Resource and then use it inside the LWC component to handle the content editing problem.

Some important parts of this solution.

The first step. Importing quill library into LWC

import QuillStaticResource from "@salesforce/resourceUrl/Quill";

The second step. In the connectedCallback I call imperative Apex to get the data from the data source. I maintain two properties: users and filtered, the first property contains all the record returned from Apex, and the second property contains only the user which match the search criteria and is updated client-side without a round-trip to the server.

The third step. I use some div with property lwc:dom="manual" and with class mention-container to use as a container for a Quill library to create content-editable div inside

<template>
    <div class="main MEDIUM" >
        <div class="publisherInputContainer" >
            <div class="input-section cuf-publisherInput forceChatterPublisherInput" data-aura-class="forceChatterPublisherInput">
                <div class="forceChatterMessageBodyInput" data-aura-class="forceChatterMessageBodyInput">
                    <div lwc:dom="manual" class="messageBodyWrapper mention-container slds-rich-text-editor slds-grid slds-grid_vertical slds-nowrap slds-has-focus forceChatterMessageBodyInputRichTextEditor">
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

The fourth step. On the first execution of the renderedCallback, I load the Quill script from the Static Resource, then on success load I query the mention container element and pass this into initQuill method.

renderedCallback() {
    if (!this.firstRender) {
        this.firstRender = true;
        Promise.all([
            //loadScript(this, QuillMention + '/src/quill.mention.js'),
            loadScript(this, QuillStaticResource + "/quill.js"),
            loadStyle(this, QuillStaticResource + "/quill.snow.css"),
        ])
            .then(() => {
                this.initQuill(
                    this.template.querySelector("div.mention-container"),
                    this
                );
            })
    }
}

The fifth step.

Copied the Quill-mention library to LWC and fixed any Lightning-Experience issues arised. InitQuill method is a result of concatenation of Constants module code, Mention Blot module code and Mention class constructor

The sixth step.

Copied Utils module code and other methods from Mention class into LWC.

The seventh step.

Updated renderList method to use Lightning classes. Updated initQuill method to create a set of embedded containers, similar to native Lightning Experience implementation. Also in initQuill method options object set the body of renderItem method to generate the desired output for my data

renderItem(item) {
    return `${item.first_name} ${item.last_name}`;
},

The eighth step.

Fixed two Lightning Experience issues with Quill.

For the first issue, when Quill.getBounds function fails with error Uncaught TypeError: leaf.domNode.getBoundingClientRect is not a function when try to use the library inside the LWC in Salesforce, I have submitted an issue to the Quill JS repository and provided a possible solution for that, however, the library owners didn't merge this PR into their develop branch as of 13th Apr 2021.

Another issue is with hasFocus. I think it is good idea to keep these two fixes as library overrides.

A fix for a hasFocus function

quill.selection.hasFocus = function () {
        return (
            this.root._template &&
            this.root._template.activeElement === this.root
        );
    };

Also, for this fix I set the _template property with a link to LWC template after quill is initialised.

quill.root._template = this.template;

A fix for a getBounds function

    quill.selection.getBounds = function (index) {
        var length =
            arguments.length > 1 && arguments[1] !== undefined
                ? arguments[1]
                : 0;

        var scrollLength = this.scroll.length();
        index = Math.min(index, scrollLength - 1);
        length = Math.min(index + length, scrollLength - 1) - index;
        // eslint-disable-next-line no-void
        let node = void 0,
            _scroll$leaf = this.scroll.leaf(index),
            _scroll$leaf2 = _slicedToArray(_scroll$leaf, 2),
            leaf = _scroll$leaf2[0],
            offset = _scroll$leaf2[1];
        if (leaf == null) {
            return null;
        }

        let _leaf$position = leaf.position(offset, true);

        let _leaf$position2 = _slicedToArray(_leaf$position, 2);

        node = _leaf$position2[0];
        offset = _leaf$position2[1];

        let range = document.createRange();
        if (length > 0) {
            range.setStart(node, offset);

            let _scroll$leaf3 = this.scroll.leaf(index + length);

            let _scroll$leaf4 = _slicedToArray(_scroll$leaf3, 2);

            leaf = _scroll$leaf4[0];
            offset = _scroll$leaf4[1];

            if (leaf == null) return null;

            let _leaf$position3 = leaf.position(offset, true);

            let _leaf$position4 = _slicedToArray(_leaf$position3, 2);

            node = _leaf$position4[0];
            offset = _leaf$position4[1];

            range.setEnd(node, offset);
            return range.getBoundingClientRect();
            // eslint-disable-next-line no-else-return
        } else {
            let side = "left";
            // eslint-disable-next-line no-void
            let rect = void 0;
            if (node instanceof Text || node.tagName === "#text") {
                if (offset < node.data.length) {
                    range.setStart(node, offset);
                    range.setEnd(node, offset + 1);
                } else {
                    range.setStart(node, offset - 1);
                    range.setEnd(node, offset);
                    side = "right";
                }
                rect = range.getBoundingClientRect();
            } else {
                rect = leaf.domNode.getBoundingClientRect();
                if (offset > 0) side = "right";
            }
            return {
                bottom: rect.top + rect.height,
                height: rect.height,
                left: rect[side],
                right: rect[side],
                top: rect.top,
                width: 0,
            };
        }
    };

The ninth step.

I had to add declarations

/* eslint-disable no-undef */
/* eslint-disable @lwc/lwc/no-inner-html */

to the top of LWC Javascript file to avoid eslint warning and

// eslint-disable-next-line @lwc/lwc/no-async-operation

to the places where setTimeout() is used.

The tenth step.

Also I had to change the implementation of highlightItem method since it didn't work in Lightning experience, so I just changed the ending of that method into

if (scrollItemInView) {
        this.mentionList.childNodes[this.itemIndex].scrollIntoView();
    }

The eleventh step.

Finally I have added some text property

@api get text() {
    return this.quill.editor.delta.ops
        .map((x) =>
            "string" == typeof x.insert ? x.insert : x.insert.mention.id
        )
        .join("");
}

set text(value) {
    this.quill.setText(value);
}

The twelfth step.

On the last step I have replaced the previously used lightning-textarea tag on the parent component

<lightning-textarea class="slds-m-left_x-small" name="input1" label="Add a comment"></lightning-textarea>

into a label and my custom mention component

            <label class="slds-form-element__label">Add a comment</label>
            <c-mention></c-mention>

To retrieve the value from the component, I used this.template.querySelector('c-mention').text and to clear the value in the component I used this.template.querySelector('c-mention').text = '';

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.