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="" />

&lt;!-- Tagged Users Preview --&gt;
&lt;div id="mentionPreview" class="mt-4"&gt;
&lt;h5 class="text-primary"&gt;Tagged Users:&lt;/h5&gt;
&lt;ul class="list-group"&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;

</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”&gt;&lt;/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 =&gt; {
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 `
&lt;div style="display:flex; align-items:center;"&gt;
&lt;div class="shimmer-avatar"&gt;
&lt;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" /&gt;
&lt;/div&gt;
&lt;span style="margin-left:8px;"&gt;${item.firstname} ${item.surname}&lt;/span&gt;
&lt;/div&gt;
`;
},
content: function (item) {
return $('&lt;a href="/profile/' + item.uid + '" data-uid="' + item.uid + '" data-image="' + item.image + '"&gt;@' + item.firstname + ' ' + item.surname + '&lt;/a&gt;')[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('&lt;li class="list-group-item text-muted"&gt;No users tagged yet.&lt;/li&gt;');
return;
}
mentioned.forEach(item =&gt; {
const safeImage = item.image
? item.image.replace(/(\.[^.]+)$/, '-avatar100$1')
: 'default.png';
previewList.append(`
&lt;li class="list-group-item d-flex align-items-center"&gt;
&lt;img src="https://your-website.com/secureuploads/User/avatar/${safeImage}"
alt="avatar" class="me-2 rounded-circle"
style="width:24px; height:24px;"&gt;
&lt;strong&gt;${item.name}&lt;/strong&gt;
&lt;small class="text-muted ms-2"&gt;(UID: ${item.uid})&lt;/small&gt;
&lt;/li&gt;
`);
});

}

$(‘#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: