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 Answer 1

3

Update for 20 Feb 2023:

I have decided to publish this component (input box with mentions) to AppExchange.

Meanwhile I can put here the link to the package version which contains the component

The link to install to productions and Dev Editions: is following: https://login.salesforce.com/packaging/installPackage.apexp?p0=04t09000000BOYuAAO.

The link to install in sandboxes is following: https://test.salesforce.com/packaging/installPackage.apexp?p0=04t09000000BOYuAAO

The link to documentation: https://docs.google.com/document/d/1H1D34UncLlbhjM6_dZWso0KZbyy7lcCqqeVCCyIMNwI/edit?usp=sharing

Detailed usage documentation https://docs.google.com/document/d/16UfAMUVsIU0iHoMVC4emNQiY3ASa3umWDKlmu-ikODI/edit?usp=sharing


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

To fix some LWC-Quill incompatibility, I made the following changes in the Quill library, in quill.js 2998 line

{
    key: 'hasFocus',
    value: function hasFocus() {
-      return document.activeElement === this.root;
+      return this.root._template && this.root._template.activeElement === this.root;
    }
  }, {

(this change is also mentioned in the eighth step)

and quill.core.js line 1987

-(_console = console)[method].apply(_console, args); // eslint-disable-line no-console
+if (console && console[method]) {
+    (_console = console)[method].apply(_console, args); // eslint-disable-line no-console
+}

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 = '';

16
  • what api version your lwc is running ? I feel like there is some Locker issues now. I switched to an aura component with apiv39 and made it work but when I change the api version to a recent one, the component is throwing several errors. Thanks !
    – AshBringer
    May 8, 2022 at 10:55
  • hmm, maybe there is some unrelated issue with Lightning Web Security feature? I have this in my production code and I haven't experiences any issues. Version of component is 53. <apiVersion>53.0</apiVersion>
    – Patlatus
    May 11, 2022 at 8:26
  • hey @Patlatus - thanks so much for the detailed writeup. I am trying to do something similar for email composer. I see a 404 error when resolving the promise on below lines. loadScript(this, quilljs + '/quill.js'), loadStyle(this, quilljs + '/themes.snow.css') the error I see is : GET https://****-dev-ed.lightning.force.com/resource/1661462360000/quill/themes.snow.css net::ERR_ABORTED 404 (Not Found) and GET https://****-dev-ed.lightning.force.com/resource/1661462360000/quill/quill.js 404 (Not Found) I have installed quill-1.3.6.zip as static resource. Aug 25, 2022 at 21:24
  • Rahul, did you load Static Resource?
    – Patlatus
    Aug 26, 2022 at 6:12
  • yes I did. When I use a zip file I see 404. When I upload direct js file the promise always is rejected and the error is undefined Promise.all([ loadScript(this, quilljs) ]) .then(() => { this.initQuill(); }) .catch(error => { debugger; } Aug 26, 2022 at 7:35

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .