Alex-D / Trumbowyg

A lightweight and amazing WYSIWYG JavaScript editor under 10kB
https://alex-d.github.io/Trumbowyg
MIT License
3.99k stars 612 forks source link

mention while typing (not a button) #926

Open mrooseboom opened 5 years ago

mrooseboom commented 5 years ago

I made another mention plugin for myself since I want it to show while typing and not have a taskbar button for it. My current code (the replace content part is a bit shaky still, working on it):

trumbowyg.mention.js:

/* ===========================================================
 * trumbowyg.mention.js v0.1
 * Mention plugin for Trumbowyg
 * http://alex-d.github.com/Trumbowyg
 * ===========================================================
 * Author : Rooseboom
 * required: php.js https://github.com/hirak/phpjs
 */

(function ($) {
    'use strict';

    $.extend(true, $.trumbowyg, {
        plugins: {
            mention: {
                init: function (){

                    $('body').append("<div id=\"mentionItem\" class=\"mentionDiv\"></div>") // add element to show list in
                    getMentionList() // fill possible mention items array
                    mention() // watch @ usage while typing
                }
            }
        }
    });
})(jQuery);

var mentionList = ""
var activeList = 0
var activeShift = 0
function getMentionList()
{
    mentionList = ["Saab","Audi","BMW","Ford"]

    // or create ajax function to retrieve array with possible values
}

function mention()
{
    $(function()
    {
        $('.trumbowyg-editor').keyup(function()
        {
            var text = GetPrevWord()
            var orgText = text
            var revWord = reverseString(text)

            if(text == "")
            {
                hideMention()
            }
            else if(trim(revWord.substring(0,1))== "") // trim is used from php.js
            {
                hideMention()
            }
            else if(text.substring(0,1)=="@" && text.length >= 1)
            {
                var revWord = revWord.substring(0,strpos(revWord,"@"))
                text = reverseString(revWord)
                text = text.toLowerCase()

                var mentionFiltered = mentionList.filter(function (el)
                {
                    el = el.toLowerCase()
                    return el.indexOf(text) >= 0
                })

                // close icon is used from icon ion list (add this to your lib or change classes
                var showHtml = "<div><i class=\"icon ion-ios-close-outline mentionclose\" onclick=\"hideMention()\"></i>"

                var firstupper = 0
                mentionFiltered.forEach(function(entry)
                {
                    firstupper = 0
                    var orgEntry = entry
                    if(entry[0] === entry[0].toUpperCase()){firstupper = 1}

                    // create underline
                    var replace = text;
                    var re = new RegExp(replace,"i")
                    if(text.length > 0)
                    {
                        entry = entry.replace(re, '<u>' + text + '</u>')
                    }

                    // redo first char uppercase if that was the case
                    if(entry.indexOf("<") == 0 && firstupper == 1){
                        entry = entry.substring(3)
                        entry = ucfirst(entry)
                        entry = "<u>"+entry
                    }

                    showHtml += "<li class=\"mentionli\" onclick=\"setMention('"+orgText+"','"+orgEntry+"')\">"+entry+"</li>"
                })
                showHtml += "</div>"

                // where to show to mention box
                var coords = getSelectionCoords()

                $("#mentionItem").html(showHtml)
                $("#mentionItem").show()
                if(text.length == 0)
                {
                    $("#mentionItem").css('left', (coords.x - 20) + 'px')
                    $("#mentionItem").css('top', (coords.y + 20) + 'px')
                }

            }
            else
            {
                hideMention()
            }
        })
    })
}

function setMention(orgText,mention)
{
    var content = $('.trumbowyg-editor').text()

    // create content to be inserted
    mention = "'mention' @"+mention

    // set the mention in the content
    var replace = orgText;
    var re = new RegExp(replace,"i")
    content = content.replace(re, mention)
    $('.trumbowyg-editor').text(content)

    hideMention()
}

function hideMention()
{
    $("#mentionItem").hide()
    activeList = 0
    activeShift = 0
}

function GetPrevWord()
{
    var text = $('.trumbowyg-editor')
    var content = text.text()
    var caretPos = strlen(content)
    var word = ReturnWord(content, caretPos)

    if (word != null)
    {
        return word
    }
}

function ReturnWord(text, caretPos)
{
    var index = text.indexOf(caretPos);
    var preText = text.substring(0, caretPos);
    if (preText.indexOf(" ") > 0)
    {
        var words = preText.split(" ");
        return words[words.length - 1]; //return last word
    }
    else
    {
        return preText;
    }
}

function reverseString(str) {
    // Step 1. Use the split() method to return a new array
    var splitString = str.split(""); // var splitString = "hello".split("");
    // ["h", "e", "l", "l", "o"]

    // Step 2. Use the reverse() method to reverse the new created array
    var reverseArray = splitString.reverse(); // var reverseArray = ["h", "e", "l", "l", "o"].reverse();
    // ["o", "l", "l", "e", "h"]

    // Step 3. Use the join() method to join all elements of the array into a string
    var joinArray = reverseArray.join(""); // var joinArray = ["o", "l", "l", "e", "h"].join("");
    // "olleh"

    //Step 4. Return the reversed string
    return joinArray; // "olleh"
}

function getSelectionCoords(win) {
    win = win || window;
    var doc = win.document;
    var sel = doc.selection, range, rects, rect;
    var x = 0, y = 0;
    if (sel) {
        if (sel.type != "Control") {
            range = sel.createRange();
            range.collapse(true);
            x = range.boundingLeft;
            y = range.boundingTop;
        }
    } else if (win.getSelection) {
        sel = win.getSelection();
        if (sel.rangeCount) {
            range = sel.getRangeAt(0).cloneRange();
            if (range.getClientRects) {
                range.collapse(true);
                rects = range.getClientRects();
                if (rects.length > 0) {
                    rect = rects[0];
                }
                x = rect.left;
                y = rect.top;
            }
            // Fall back to inserting a temporary element
            if (x == 0 && y == 0) {
                var span = doc.createElement("span");
                if (span.getClientRects) {
                    // Ensure span has dimensions and position by
                    // adding a zero-width space character
                    span.appendChild( doc.createTextNode("\u200b") );
                    range.insertNode(span);
                    rect = span.getClientRects()[0];
                    x = rect.left;
                    y = rect.top;
                    var spanParent = span.parentNode;
                    spanParent.removeChild(span);

                    // Glue any broken text nodes back together
                    spanParent.normalize();
                }
            }
        }
    }
    return { x: x, y: y };
}

and in the ui folder trumbowyg.mention.css:

/**
 * Trumbowyg v2.14.0 - A lightweight WYSIWYG editor
 * Trumbowyg plugin stylesheet
 * ------------------------
 * @link http://alex-d.github.io/Trumbowyg
 * @license MIT
 * @author plugin: Rooseboom
 */

.mentionclose
{
    float: right;
    cursor: pointer;
    position: relative;
    top:-10px;
    left:8px;
}

.mentionDiv
{
    position:absolute;
    display:none;
    z-index: 1000000;
    padding:10px;
    background: #FFFFFF;
    width:300px;
    border: 2px solid #dee2e6;
    border-radius: 4px;
}

.mentionli
{
    list-style-type: none;
    cursor: pointer;
    padding:2px;
}

.mentionli:hover
{
    list-style-type: none;
    cursor: pointer;
    background-color: #73c3d8;
    color:#FFFFFF;
}

mntn
{
    text-decoration: underline;
    color:#f26322;
    display:inline-block;
}
mrooseboom commented 5 years ago

change the setMention function:

function setMention(orgText,mention)
{
    var content = $('.trumbowyg-editor').html()

    // create content to be inserted
    mention = "<mntn>"+mention+"</mntn>&nbsp;"
    var regex = new RegExp("" + orgText + "", "g");
    content = content.replace(regex, mention)
    $('.trumbowyg-editor').html(content)
    hideMention()
}
mrooseboom commented 5 years ago

another addition (close the mention list when you click outside):

$(document).mouseup(function(e)
{
    var container = $("#mentionItem")

    // if the target of the click isn't the container nor a descendant of the container
    if (!container.is(e.target) && container.has(e.target).length === 0)
    {
        hideMention()
    }
})
philippejadin commented 4 years ago

this would be really nice as a plugin (or even nicer integrated in the "official" mention plugin)

mrooseboom commented 4 years ago
/* ===========================================================
 * trumbowyg.mention.js v0.2
 * Mention plugin for Trumbowyg
 * http://alex-d.github.com/Trumbowyg
 * ===========================================================
 * Author : Rooseboom
 * required: php.js https://github.com/hirak/phpjs
 */

(function ($) {
    'use strict';

    $.extend(true, $.trumbowyg, {
        plugins: {
            mention: {
                init: function (){

                    $('body').append("<div id=\"mentionItem\" class=\"mentionDiv\"></div>") // add element to show list in
                    getMentionList() // fill possible mention items array
                    mention() // watch @ usage while typing
                }
            }
        }
    });
})(jQuery);

var mentionList = ""
var activeList = 0
var activeShift = 0
function getMentionList()
{
    mentionList = ["Saab","Audi","BMW","Ford"]

    // or create ajax function to retrieve array with possible values
}

$(document).mouseup(function(e)
{
    var container = $("#mentionItem")

    // if the target of the click isn't the container nor a descendant of the container
    if (!container.is(e.target) && container.has(e.target).length === 0)
    {
        hideMention()
    }
})

function mention()
{
    $(function()
    {
        $('.trumbowyg-editor').keyup(function()
        {
            var text = GetPrevWord()
            text = text.substring(strpos(text,"@"))
            var orgText = text
            var revWord = reverseString(text)
            //console.log(revWord+" "+orgText)

            if(trim(revWord.substring(0,1))== "") // trim is used from php.js
            {
                hideMention()
            }
            else if(text.substring(0,1)=="@" && text.length > 1)
            {
                var revWord = revWord.substring(0,strpos(revWord,"@"))
                text = reverseString(revWord)
                var replaceText = "@"+text;
                text = text.toLowerCase()

                var mentionFiltered = mentionList.filter(function (el)
                {
                    el = el.toLowerCase()
                    return el.indexOf(text) >= 0
                })

                // close icon is used from icon ion list (add this to your lib or change classes
                var showHtml = "<div><i class=\"icon ion-ios-close-outline mentionclose\" onclick=\"hideMention()\"></i>"

                var firstupper = 0
                mentionFiltered.forEach(function(entry)
                {
                    firstupper = 0
                    var splitArr = entry.split("|")
                    var entrypartName = splitArr[0]
                    var entrypartId = splitArr[1]
                    var orgEntry = entrypartName+"<mntnid>"+entrypartId+"</mntnid>"

                    if(entrypartName[0] === entrypartName[0].toUpperCase()){firstupper = 1}

                    // create underline
                    var replace = text;
                    var re = new RegExp(replace,"i")
                    if(text.length > 0)
                    {
                        entrypartName = entrypartName.replace(re, '<u>' + text + '</u>')
                    }

                    // redo first char uppercase if that was the case
                    if(entrypartName.indexOf("<") == 0 && firstupper == 1){
                        entrypartName = entrypartName.substring(3)
                        entrypartName = ucfirst(entrypartName)
                        entrypartName = "<u>"+entrypartName
                    }

                    showHtml += "<li class=\"mentionli\" onclick=\"setMention('"+replaceText+"','"+orgEntry+"')\">"+entrypartName+"</li>"
                })
                showHtml += "</div>"

                // where to show to mention box
                var coords = getSelectionCoords()

                $("#mentionItem").html(showHtml)
                $("#mentionItem").show()

                if(text.length >= 0)
                {
                    if($("#mentionItem"))
                    {
                        $("#mentionItem").css('left', (coords.x - 20) + 'px')
                        $("#mentionItem").css('top', (coords.y + 20) + 'px')
                    }
                }

            }
            else
            {
                hideMention()
            }
        })
    })
}

function setMention(orgText,mention)
{
    var content = $('.trumbowyg-editor').html()

    // create content to be inserted
    mention = "<mntn>"+mention+"</mntn>&nbsp;"
    var regex = new RegExp("" + orgText + "", "g");
    //console.log(orgText+" > "+mention)
    content = content.replace(regex, mention)
    $('.trumbowyg-editor').html(content)

    hideMention()
}

function hideMention()
{
    $("#mentionItem").hide()
}

function GetPrevWord()
{
    var text = $('.trumbowyg-editor')
    var elementId = text[0].id
    var content = text.text()

    var element = document.getElementById(elementId)
    var caretPos = getCaretCharacterOffsetWithin(element)

    var word = ReturnWord(content, caretPos)

    if (word != null)
    {
        return word
    }
}

function ReturnWord(text, caretPos)
{
    var index = text.indexOf(caretPos);
    var preText = text.substring(0, caretPos);
    if (preText.indexOf(" ") > 0)
    {
        var words = preText.split(" ");
        return words[words.length - 1]; //return last word
    }
    else
    {
        return preText;
    }
}

function reverseString(str) {
    // Step 1. Use the split() method to return a new array
    var splitString = str.split(""); // var splitString = "hello".split("");
    // ["h", "e", "l", "l", "o"]

    // Step 2. Use the reverse() method to reverse the new created array
    var reverseArray = splitString.reverse(); // var reverseArray = ["h", "e", "l", "l", "o"].reverse();
    // ["o", "l", "l", "e", "h"]

    // Step 3. Use the join() method to join all elements of the array into a string
    var joinArray = reverseArray.join(""); // var joinArray = ["o", "l", "l", "e", "h"].join("");
    // "olleh"

    //Step 4. Return the reversed string
    return joinArray; // "olleh"
}

function getSelectionCoords(win) {
    win = win || window;
    var doc = win.document;
    var sel = doc.selection, range, rects, rect;
    var x = 0, y = 0;
    if (sel) {
        if (sel.type != "Control") {
            range = sel.createRange();
            range.collapse(true);
            x = range.boundingLeft;
            y = range.boundingTop;
        }
    } else if (win.getSelection) {
        sel = win.getSelection();
        if (sel.rangeCount) {
            range = sel.getRangeAt(0).cloneRange();
            if (range.getClientRects) {
                range.collapse(true);
                rects = range.getClientRects();
                if (rects.length > 0) {
                    rect = rects[0];
                }
                if(rect)
                {
                    x = rect.left;
                    y = rect.top;
                }
                else
                {
                    x = 0
                    y = 0
                }
            }
            // Fall back to inserting a temporary element
            if (x == 0 && y == 0) {
                var span = doc.createElement("span");
                if (span.getClientRects) {
                    // Ensure span has dimensions and position by
                    // adding a zero-width space character
                    span.appendChild( doc.createTextNode("\u200b") );
                    range.insertNode(span);
                    rect = span.getClientRects()[0];
                    x = rect.left;
                    y = rect.top;
                    var spanParent = span.parentNode;
                    spanParent.removeChild(span);

                    // Glue any broken text nodes back together
                    spanParent.normalize();
                }
            }
        }
    }
    return { x: x, y: y };
}

function getCaretCharacterOffsetWithin(element) {
    var caretOffset = 0;
    var doc = element.ownerDocument || element.document;
    var win = doc.defaultView || doc.parentWindow;
    var sel;
    if (typeof win.getSelection != "undefined") {
        sel = win.getSelection();
        if (sel.rangeCount > 0) {
            var range = win.getSelection().getRangeAt(0);
            var preCaretRange = range.cloneRange();
            preCaretRange.selectNodeContents(element);
            preCaretRange.setEnd(range.endContainer, range.endOffset);
            caretOffset = preCaretRange.toString().length;
        }
    } else if ( (sel = doc.selection) && sel.type != "Control") {
        var textRange = sel.createRange();
        var preCaretTextRange = doc.body.createTextRange();
        preCaretTextRange.moveToElementText(element);
        preCaretTextRange.setEndPoint("EndToEnd", textRange);
        caretOffset = preCaretTextRange.text.length;
    }
    return caretOffset;
}

function getCaretPosition() {
    if (window.getSelection && window.getSelection().getRangeAt) {
        var range = window.getSelection().getRangeAt(0);
        var selectedObj = window.getSelection();
        var rangeCount = 0;
        var childNodes = selectedObj.anchorNode.parentNode.childNodes;
        for (var i = 0; i < childNodes.length; i++) {
            if (childNodes[i] == selectedObj.anchorNode) {
                break;
            }
            if (childNodes[i].outerHTML)
                rangeCount += childNodes[i].outerHTML.length;
            else if (childNodes[i].nodeType == 3) {
                rangeCount += childNodes[i].textContent.length;
            }
        }
        return range.startOffset + rangeCount;
    }
    return -1;
}

Made some improvements

mrooseboom commented 4 years ago

this would be really nice as a plugin (or even nicer integrated in the "official" mention plugin)

It actually is set up as a plugin and I use it that way. It is just not offical ;-)

philippejadin commented 4 years ago

Could you explain what advantage brings php.js and why it's required? It would be nice to avoid it, since it looks even hard to just download it. Thanks!

mrooseboom commented 4 years ago

it is mentioned in the code: else if(trim(revWord.substring(0,1))== "") // trim is used from php.js

philippejadin commented 4 years ago

I almost have a working version without php.js, thank you thousands times for providing this. Will post a fully working example as soon as possible.

Let's try to push this as an official plugin. I think the best way would be to extend the official mention plugin. What @Alex-D thinks about that idea?

Alex-D commented 4 years ago

Please make a PR

philippejadin commented 4 years ago

I tried hard but I have to say that it ended up being too hard for my abilities :-/ I still think this would make a nice addition but have'nt the needed resources to do it myself.

mrooseboom commented 4 years ago

Would replacing the trim function with this one (so replace trim with PluginTrim) be enough to drop php.js? I have no time to test it and use php.js in my project so I don't need it

function PluginTrim( str, charlist ) {
    // Strip whitespace (or other characters) from the beginning and end of a string
    // 
    // +    discuss at: http://kevin.vanzonneveld.net/techblog/article/javascript_equivalent_for_phps_trim/
    // +       version: 804.1712
    // +   original by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
    // +   improved by: mdsjack (http://www.mdsjack.bo.it)
    // +   improved by: Alexander Ermolaev (http://snippets.dzone.com/user/AlexanderErmolaev)
    // +      input by: Erkekjetter
    // +   improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
    // +      input by: DxGx
    // +   improved by: Steven Levithan (http://blog.stevenlevithan.com)
    // *     example 1: trim('    Kevin van Zonneveld    ');
    // *     returns 1: 'Kevin van Zonneveld'
    // *     example 2: trim('Hello World', 'Hdle');
    // *     returns 2: 'o Wor'

    var whitespace;

    if(!charlist){
        whitespace = ' \n\r\t\f\x0b\xa0\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u2028\u2029\u3000';
    } else{
        whitespace = charlist.replace(/([\[\]\(\)\.\?\/\*\{\}\+\$\^\:])/g, '\$1');
    }

    for (var i = 0; i < str.length; i++) {
        if (whitespace.indexOf(str.charAt(i)) === -1) {
        str = str.substring(i);
        break;
        }
    }
    for (i = str.length - 1; i >= 0; i--) {
        if (whitespace.indexOf(str.charAt(i)) === -1) {
            str = str.substring(0, i + 1);
            break;
        }
    }
    return whitespace.indexOf(str.charAt(0)) === -1 ? str : '';
}
Alex-D commented 4 years ago

Why not just the simple and native string.trim()?

mrooseboom commented 4 years ago

yeah that would be fine as well ;-)

synth commented 2 years ago

We would love this to become the official or alternatively official plugin! If not possible to add to Trumbowyg official, @mrooseboom - how would you feel about creating a separate repo so we have an authoritative source that we can file issues and contribute PR's - to make this more "official" in our own way.

janpeterka commented 2 years ago

Hey, any news on this, @mrooseboom ? I would need this so much in my current project!

Alex-D commented 2 years ago

You can create a Pull Request with the plugin and its documentation if anyone wants. I will be happy to review it :)

solsticesurfer commented 1 year ago

This would be great to have. I tried out the revised code above but immediately got the following exception on the first keypress:

    trumbowyg.mention-0.2.js:264 Uncaught TypeError: Cannot read properties of null (reading 'ownerDocument')
    at getCaretCharacterOffsetWithin (trumbowyg.mention-0.2.js:264:23)
    at GetPrevWord (trumbowyg.mention-0.2.js:164:20)
    at HTMLDivElement.<anonymous> (trumbowyg.mention-0.2.js:57:24)
    at HTMLDivElement.dispatch (jquery-3.1.1.min.js:3:10315)
    at q.handle (jquery-3.1.1.min.js:3:8342)
Infin48 commented 1 year ago

This plugin didn't work for me, I had same problem as @solsticesurfer and i needed it so I reworked it from @mrooseboom. But i had one problem I didn't know how to deal with. So there is one limit and that is that each user can be mentioned only once in every trumbowyg editor.

In file trumbowyg.mention.js in the top you find all HTML used for this plugin and also variable mentionUserList where you must save data about users. Format of this data is in the comment above.

To show list of users you have to write at least three characters!

Plugin: mention.zip

Demo with already created ajax requests: Mention plugin

Alhiane commented 1 year ago

This plugin didn't work for me, I had same problem as @solsticesurfer and i needed it so I reworked it from @mrooseboom. But i had one problem I didn't know how to deal with. So there is one limit and that is that each user can be mentioned only once in every trumbowyg editor.

In file trumbowyg.mention.js in the top you find all HTML used for this plugin and also variable mentionUserList where you must save data about users. Format of this data is in the comment above.

To show list of users you have to write at least three characters!

Plugin: mention.zip

Demo with already created ajax requests: Mention plugin Mention plugin

i found the solution now the mention can start fetching after one typo after @ just replace the folwing code // Get entered text by last at-sign position text = text.substring(lastAtSignCaretPosition, lastAtSignCaretPosition + currentCaretPosition - lastAtSignCaretPosition); if (text.length <= 2) { mentionList.hide(); return; }

replace 2 to