31

tl;dr

The idea is to allow a user to mark any text and see menu pop-up just next to the selection, with possible actions to apply to the selected text.


I need to position an absolute positioned button next to user's selected text.

I'm binding a mouseup event to the Document, and getting the selected text, but I'm currently out of ideas on how to know where the actual selection is positioned, without wrapping it in some element, because selection of text can be across several elements, and it would mess the structure if I would wrap it.

Community
  • 1
  • 1
vsync
  • 118,978
  • 58
  • 307
  • 400

3 Answers3

30

You could position a marker span at the end of the selection, get its coordinates using jQuery, place your button at those coordinates and remove the marker span.

The following should get you started:

var markSelection = (function() {
    var markerTextChar = "\ufeff";
    var markerTextCharEntity = "";

    var markerEl, markerId = "sel_" + new Date().getTime() + "_" + Math.random().toString().substr(2);

    var selectionEl;

    return function(win) {
        win = win || window;
        var doc = win.document;
        var sel, range;
        // Branch for IE <= 8 
        if (doc.selection && doc.selection.createRange) {
            // Clone the TextRange and collapse
            range = doc.selection.createRange().duplicate();
            range.collapse(false);

            // Create the marker element containing a single invisible character by creating literal HTML and insert it
            range.pasteHTML('<span id="' + markerId + '" style="position: relative;">' + markerTextCharEntity + '</span>');
            markerEl = doc.getElementById(markerId);
        } else if (win.getSelection) {
            sel = win.getSelection();
            range = sel.getRangeAt(0).cloneRange();
            range.collapse(false);

            // Create the marker element containing a single invisible character using DOM methods and insert it
            markerEl = doc.createElement("span");
            markerEl.id = markerId;
            markerEl.appendChild( doc.createTextNode(markerTextChar) );
            range.insertNode(markerEl);
        }

        if (markerEl) {
            // Lazily create element to be placed next to the selection
            if (!selectionEl) {
                selectionEl = doc.createElement("div");
                selectionEl.style.border = "solid darkblue 1px";
                selectionEl.style.backgroundColor = "lightgoldenrodyellow";
                selectionEl.innerHTML = "&lt;- selection";
                selectionEl.style.position = "absolute";

                doc.body.appendChild(selectionEl);
            }

            // Find markerEl position http://www.quirksmode.org/js/findpos.html
        var obj = markerEl;
        var left = 0, top = 0;
        do {
            left += obj.offsetLeft;
            top += obj.offsetTop;
        } while (obj = obj.offsetParent);

            // Move the button into place.
            // Substitute your jQuery stuff in here
            selectionEl.style.left = left + "px";
            selectionEl.style.top = top + "px";

            markerEl.parentNode.removeChild(markerEl);
        }
    };
})();
Tim Down
  • 318,141
  • 75
  • 454
  • 536
  • 1
    When I executed this code the top variable was an object. Probably there was a global variable called top. It is better to write var left = 0; var top = 0; instead of var left = top = 0; – Zoltan Kochan Jun 19 '13 at 18:31
  • @Z-CORE: You're absolutely right: `top` is a reference to the outermost window containing the the current document. It's very unusual for me to have copied and pasted someone else's code in (from quirksmode.org in this case), but at least it's clear the error is not originally mine :) – Tim Down Jun 19 '13 at 23:00
  • 2
    While unlikely, there may exist css rules that depend on certain element orders like `#example>span+span`. inserting the marker span into the content would disrupt those rules potentially altering/breaking the layout. The range.getBoundingClientRect() might be a more robust solution. – Rick Jun 08 '15 at 19:25
  • @Rick: True. The code required to convert a position relative to the viewport (which is what `getBoundingClientRect()` returns) into a position relative to the entire document in major browsers is sufficiently complicated to put me off adding it here. – Tim Down Jun 08 '15 at 22:59
  • Won't this code give error: `undefined has no property named setStart` for browsers with older webkit? There is a missng assignment for range variable at line 26. – Deepak May 01 '17 at 07:57
  • @Deepak: You're right, thanks. We're talking really old WebKit here so in practice that branch is never going to be used now, but I'll correct it. – Tim Down May 02 '17 at 11:39
  • This code does not work inside a frame properly. The position of the created span shows up in the wrong place or is hidden completely. I will update this answer if I find a solution. – user1254723 Apr 06 '19 at 13:55
  • @user1254723: With a couple of modifications (which I originally left out because the question didn't ask for it), it will work fine in a frame. I'll make the changes. – Tim Down Apr 08 '19 at 09:58
  • @user1254723: You can now pass in the frame's `Window` object into the `markSelection ` function. – Tim Down Apr 08 '19 at 10:04
  • (1) This will not work within a `textarea`. If you need it within a `textarea`, consider a `
    foo
    ` (2) If you want to trigger this function when the user selected something, use `document.addEventListener ("selectionchange", ...);`
    – DarkTrick Jan 02 '21 at 14:30
22

I use getBoundingClientRect() when I need the content to remain undisturbed, while placing additional content near it.

    var r=window.getSelection().getRangeAt(0).getBoundingClientRect();
    var relative=document.body.parentNode.getBoundingClientRect();
    ele.style.top =(r.bottom -relative.top)+'px';//this will place ele below the selection
    ele.style.right=-(r.right-relative.right)+'px';//this will align the right edges together

this works in Chrome, but IE likes to give weird things, so here's a cross-browser solution: (Tested in Chrome and IE, probably works elsewhere)

https://jsfiddle.net/joktrpkz/7/

Rick
  • 1,240
  • 14
  • 21
  • 1
    Hi, can you explain why do you use cal1/cal2 in your fiddle? thanks. – Guy S Dec 30 '15 at 10:33
  • @Guy It was to deal with the way IE handles pixels when zoomed in. I don't remember if it was necessary or not in various other scenarios. – Rick Dec 31 '15 at 14:17
  • my requirement are only for my extension to work under chrome so this solution is working great for me. – SKYnine Mar 28 '17 at 11:28
2

You should probably insert an absolutely position element at the end of the 'range.' This works differently in different browsers, so your best bet might be to sniff.

And since you asked: this is how the new york times does it in their 'altClickToSearch.js' file:

function insertButton() {

selectionButton = new Element(
        'span', {
          'className':'nytd_selection_button',
          'id':'nytd_selection_button',
          'title':'Lookup Word',
          'style': 'margin:-20px 0 0 -20px; position:absolute; background:url(http://graphics8.nytimes.com/images/global/word_reference/ref_bubble.png);width:25px;height:29px;cursor:pointer;_background-image: none;filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src="http://graphics8.nytimes.com/images/global/word_reference/ref_bubble.png", sizingMethod="image");'
        }
    )

if (Prototype.Browser.IE) {
  var tmp = new Element('div');
  tmp.appendChild(selectionButton);
  newRange = selection.duplicate();
  newRange.setEndPoint( "StartToEnd", selection);
  newRange.pasteHTML(tmp.innerHTML);
  selectionButton = $('nytd_selection_button');
}
else {
  var range = selection.getRangeAt(0);
  newRange = document.createRange();
  newRange.setStart(selection.focusNode, range.endOffset);
  newRange.insertNode(selectionButton);
}
}
Alex Sexton
  • 10,401
  • 2
  • 29
  • 41
  • Thanks, yes they appear to just insert it at that specific point in the selection instead of absolute to the Document itself. thanks! – vsync Oct 19 '09 at 18:20
  • 3
    There's a needless browser sniff in there. You can just check for the objects/methods you need. Also, the selection object in older WebKits (Safari 2, maybe 3, I'm a bit hazy on this and I don't have all the relevant browsers easily to hand) don't have a `getRangeAt` method. – Tim Down Oct 19 '09 at 20:18
  • I wasn't suggesting the code above as the best solution, especially with the browser sniff. He asked about the nytimes in his comment. This is just the code from that site. – Alex Sexton Mar 16 '11 at 20:18