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 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:

6 Likes

Looks impressive, will have to have a play!

1 Like

Its a great bit of kit - works a treat..! It'll also incorporate hashtags if reuired.

wow! this is impressive.. i cant even think of the amount of work that has gone into this... i dont have a use case for this myself.. but just to see what you guys accomplish with Wappler is amazing... once again it just shows how awesome Wappler is.. and forget how flexible it is...... nice work @TMR .... and to the Wappler Team ....

1 Like

Hey, thanks for those kind words @Mozzi - there was indeed a lot of work in there - especially considering javascript is really not one of my strong points. I'm sure there's a better way to do it but that's for better brains than me to find out :slight_smile:

Of course, none of it would be possible for me to do without the wonderful Wappler, it's a game changer.

1 Like