I wanted to add an editor that supported tags to an existing web application that used knockout.js (I know, not what the cool kids are using but a full re-write just to add tags seemed ill-advised). After persevering with ProseMirror I decided it was too rich for what i wanted. All I needed was plain text + tags, not the full richness of ProseMirror.
After doing a bit of investigation I found a nice little library called At.js. I put together the following binding extension to allow At.js to be ‘data-bound’.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
ko.bindingHandlers.tagValue = { | |
init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { | |
var replaceTags = function(taggedString, targetUrl, linkClass) { | |
if (!taggedString) { | |
return null; | |
} | |
var exp = /(^#|\W#)[\w-_]+/g; | |
var match = taggedString.match(exp); | |
if (match && match.length > 0) { | |
for (var i = 0; i < match.length; i++) { | |
var tag = match[i].trim(); | |
if (tag != "#") { | |
var uri = " <span class='atwho-inserted' data-atwho-at-query='" + tag + "'><a href='" + targetUrl + tag.replace("#", "") + "' class='" + linkClass + "'>" + tag + "</a></span>"; | |
taggedString = taggedString.replace(match[i], uri); | |
} | |
} | |
} | |
return taggedString; | |
}; | |
var tags = allBindings.get('tags'); | |
var tagUri = allBindings.get('tagUri'); | |
// set current value | |
var value = valueAccessor(); | |
var valueUnwrapped = ko.unwrap(value); | |
element.innerHTML = replaceTags(valueUnwrapped, tagUri + "?tag=", "tag"); | |
$(element).atwho({ | |
at: "#", | |
data: tags(), | |
insertTpl: '<a class="tag" href="' + tagUri + '?tag=${name}">#${name}</a>' | |
}); | |
$(element).on('paste', function (e) { | |
e.preventDefault(); | |
var text = (e.originalEvent || e).clipboardData.getData('text/plain'); | |
document.execCommand('insertText', false, text); | |
}); | |
$(element).on('keydown', function (e) { | |
if (e.ctrlKey && (String.fromCharCode(e.which).toLowerCase() === 'b' || String.fromCharCode(e.which).toLowerCase() === 'i' || String.fromCharCode(e.which).toLowerCase() === 'u')) { | |
e.preventDefault(); | |
} | |
if (e.which == 13) { | |
e.preventDefault(); // suppress enter key | |
} | |
}); | |
$(element).blur(function () { | |
var value = valueAccessor(); | |
value(element.textContent); | |
}); | |
ko.utils.domNodeDisposal.addDisposeCallback(element, function () { | |
$(element).atwho('destroy'); | |
}); | |
}, | |
update: function (element, valueAccessor, allBindings, viewModel, bindingContext) { | |
// This will be called once when the binding is first applied to an element, | |
// and again whenever any observables/computeds that are accessed change | |
// Update the DOM element based on the supplied values here. | |
var tags = allBindings.get('tags'); | |
$(element).atwho('load', '#', tags()); | |
var tagUri = allBindings.get('tagUri'); | |
var value = valueAccessor(); | |
var valueUnwrapped = ko.unwrap(value); | |
element.innerHTML = util.replaceTags(valueUnwrapped, tagUri + "?tag=", "tag"); | |
} | |
}; |
Usage is something like this:
<div contenteditable="true" data-bind="tagValue: Description, tags: $root.tags, tagUri:'/your-url-to-tags-page/'"></div>
And it will look like this (with here shown with bootstrap)