Skip to content

Advanced Summernote with AJAX, Fuse Fuzzy Search, Avatars and ID Bindings

I needed to create a chat application for a NodeJS project, one of the main elements is a form input where users can post their initial messages. I was having i

I needed to create a chat application for a NodeJS project, one of the main elements is a form input where users can post their initial messages. I was having issues with adding @mentions into a summernote lite text area - turned out to be a lot of work so I thought I'd post my code here in case anyone else needs a leg up.. It's all part of my little attempts to put back a little of what I've taken out from this excellent forum :slight_smile:

This isn't really intended to be a tutorial but I'll briefly explain what the code does:

  • Takes live data from an internal (JSON Array) API feed - would work on any public API data format for this one is [{"uid":73382,"firstname":"Joe","surname":"Bloggs","image":"73382.jpg"}
  • Uses Summernote hints to facilitate an @mentions type search of users in the API above
  • Uses Fuse.js to provide fuzzy search, firstnames and surnames can be weighted in the search
  • Includes preloaded avatars in the selection list, its very lightweight
  • Includes a nifty shimmering placeholder whilst images load
  • fallback avatar for when images don't load
  • Changes the stored image filename on-the-fly - I have the users avatar stored in three different versions on the server, but only one filename listed in the db (DUH) so I had to change the listed filename from what is in the db 12345.jpg to the one we plan to use that is stored in the file system 12345-avatar100.jpg
  • Uses a hidden txt field to send an array of selected @mentions UIDs during submit for backend processing
  • Real-time preview 'Tagged Users' in a list below the textarea - this also includes the users avatar.

image

Full code for it is here:

<!-- Wappler include head-page="layouts/main" bootstrap5="local" is="dmx-app" id="summernoteMentionApp" appConnect="local" fontawesome_6="local" components="{dmxSummernote:{}}" jquery="cdn" -->
<meta name="ac:route" content="/test/test-basic-summernote">

<!-- Summernote Editor Section -->
<div class="container py-5">
    <div class="row justify-content-center">
        <div class="col-lg-8">
            <div class="card shadow-lg">
                <div class="card-header bg-primary-subtle text-primary-emphasis">
                    <h3 class="mb-0"><i class="fa-solid fa-comment-dots"></i> Advanced Summernote Editor with @Mentions</h3>
                </div>
                <div class="card-body">
                    <textarea id="summernote" name="summernote" dmx-bind:config="editorConfig" is="dmx-summernote"></textarea>
                    <button id="btnSubmit" class="btn btn-primary mt-3">Add message</button>
                    <input type="hidden" id="mentionUIDs" name="mentionUIDs" value="" />

                    <!-- Tagged Users Preview -->
                    <div id="mentionPreview" class="mt-4">
                        <h5 class="text-primary">Tagged Users:</h5>
                        <ul class="list-group"></ul>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

<style>
    .shimmer-avatar {
        position: relative;
        width: 24px;
        height: 24px;
        border-radius: 50%;
        overflow: hidden;
        background: #f0f0f0;
    }

    .shimmer-avatar::before {
        content: '';
        position: absolute;
        top: 0;
        left: -40px;
        width: 40px;
        height: 100%;
        background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.6), transparent);
        animation: shimmer 1.2s infinite;
    }

    @keyframes shimmer {
        100% {
            transform: translateX(100px);
        }
    }
</style>


<!-- Load Fuse.js for fuzzy search -->
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2"></script>

<script>
    function setupEditorConfig() {
    var editorConfig = {
      height: 200,
      placeholder: "Type @ to mention someone in the area",
      hint: {
        match: /\B@([\w.-]+)$/,
        search: function (keyword, callback) {
          $.ajax({
            url: 'https://your-website.com/api/chat/FrontEndUsers',
            type: 'GET',
            dataType: 'json',
            success: function (data) {
              const fuse = new Fuse(data.titles, {
                keys: [
                  { name: 'firstname', weight: 0.6 },
                  { name: 'surname', weight: 0.4 }
                ],
                threshold: 0.3,
                ignoreLocation: true,
                minMatchCharLength: 1
              });

              const results = fuse.search(keyword);

              const filtered = results.map(result => {
                const item = result.item;
                const preloadImg = new Image();
                preloadImg.src = 'https://your-website.com/uploads/avatar/' + item.image;
                return {
                  firstname: item.firstname,
                  surname: item.surname,
                  uid: item.uid,
                  image: item.image
                };
              });

              callback(filtered);
            },
            error: function () {
              callback([]);
            }
          });
        },
        template: function (item) {
          const finalImage = item.image.replace(/(\.[^.]+)$/, '-avatar100$1');

          return `
            <div style="display:flex; align-items:center;">
              <div class="shimmer-avatar">
                <img src="https://your-website.com/uploads/avatar/${finalImage}" 
                  style="width:24px; height:24px; border-radius:50%;"
                  onload="this.parentNode.classList.remove('shimmer-avatar');"
                  onerror="this.onerror=null; this.src='https://your-website.com/uploads/avatar/default.png'; this.parentNode.classList.remove('shimmer-avatar');"
                  alt="avatar" />
              </div>
              <span style="margin-left:8px;">${item.firstname} ${item.surname}</span>
            </div>
          `;
        },
        content: function (item) {
          return $('<a href="/profile/' + item.uid + '" data-uid="' + item.uid + '" data-image="' + item.image + '">@' + item.firstname + ' ' + item.surname + '</a>')[0];
        }
      },
      toolbar: [
        ['style', ['style', 'cleaner']],
        ['font', ['bold', 'underline', 'clear']],
        ['para', ['ul', 'ol', 'paragraph']],
        ['table', ['table']],
        ['insert', ['link', 'picture', 'video']],
        ['view', ['fullscreen', 'help']]
      ]
    };

    dmx.global.set('editorConfig', editorConfig);

    // Attach live update for preview
    $('#summernote').on('summernote.change', function () {
      updateMentionPreview();
    });
  }

  function updateMentionPreview() {
    const content = $('#summernote').summernote('code');
    const tempDiv = $('<div>').html(content);
    const mentioned = [];

    tempDiv.find('a[data-uid]').each(function () {
      const uid = $(this).attr('data-uid');
      const name = $(this).text().replace(/^@/, '').trim();
      const image = $(this).attr('data-image');
      mentioned.push({ uid, name, image });
    });

    const previewList = $('#mentionPreview ul');
    previewList.empty();

    if (mentioned.length === 0) {
      previewList.append('<li class="list-group-item text-muted">No users tagged yet.</li>');
      return;
    }

    mentioned.forEach(item => {
      const safeImage = item.image
        ? item.image.replace(/(\.[^.]+)$/, '-avatar100$1')
        : 'default.png';

      previewList.append(`
        <li class="list-group-item d-flex align-items-center">
          <img src="https://your-website.com/secureuploads/User/avatar/${safeImage}" 
               alt="avatar" class="me-2 rounded-circle" 
               style="width:24px; height:24px;">
          <strong>${item.name}</strong> 
          <small class="text-muted ms-2">(UID: ${item.uid})</small>
        </li>
      `);
    });
  }

  $('#btnSubmit').on('click', function () {
    const content = $('#summernote').summernote('code');
    const tempDiv = $('<div>').html(content);
    const uidList = [];

    tempDiv.find('a[data-uid]').each(function () {
      uidList.push($(this).attr('data-uid'));
    });

    $('#mentionUIDs').val(JSON.stringify(uidList));
  });
</script>

Notes Doesn't like to work with jquery slim hence the 'full fat' version here jquery="cdn"

Its reasonably difficult to debug as some of the errors are completely silent, use your AI of choice to help you - it'll do a grand job. Wapplers internal AI is a great place to start - it's getting really good.

The search allows for seperate weighting for each search term - A threshold of 0.0 requires a perfect match (of both letters and location), a threshold of 1.0 would match anything. Theres more info on the fuzzy search here, Its great for small to reasonably large datasets.:

Hope this helps someone out there..! :slight_smile: