Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

JavaScript convert mouse position to selection range

I would like to be able to convert the current mouse position to a range, in CKEditor in particular.

The CKEditor provides an API for setting the cursor according to a range:

var ranges = new CKEDITOR.dom.range( editor.document );
editor.getSelection().selectRanges( [ ranges ] );

Since CKEditor provides this API, the problem may be simplified by removing this requirement and just find a way to produce the range from the mouse coordinates over a div containing various HTML elements.

However, this is not the same as converting a mouse coordinate into the cursor position in a textarea since textareas have fixed column widths and row heights where the CKEditor renders HTML through an iframe.

Based on this, it looks like the range may be applied to elements.

How would you figure out the start/end range which is closest to the current mouse position?

Edit: An example of how one might use the ckeditor API to select a range on the mouseup event.

editor.document.on('mouseup', function(e) {
    this.focus();
    var node = e.data.$.target;

    var range = new CKEDITOR.dom.range( this.document );
    range.setStart(new CKEDITOR.dom.node(node), 0);
    range.collapse();

    var ranges = [];
    ranges.push(range);
    this.getSelection().selectRanges( ranges );
});

The problem with the above example is that the event target node (e.data.$.target) is only firing for nodes such as HTML, BODY, or IMG but not for text nodes. Even if it did, these nodes represent chunks of text which wouldn't support setting the cursor to the position of the mouse within that chunk of text.

like image 790
mrwrk Avatar asked Nov 14 '11 21:11

mrwrk


3 Answers

What you're trying to do is really hard in a browser. I'm not familar with ckeditor in particular, but regular javascript allows you to select text using a range so I don't think it's adding anything special. You have to find the browser element that contains the click, then find the character within the element that was clicked.

Detecting the browser element is the easy bit: you need to either register your handler on every element, or use the event's target field. There is lots of info on this out there, ask a more specific question on stackoverflow if that's what you're having trouble with.

Once you have the element you need to find out which character within the element was clicked, then create an appropriate range to put the cursor there. As the post you linked to stated, browser variations make this really hard. This page is a bit dated, but has a good discussion of ranges: http://www.quirksmode.org/dom/range_intro.html

Ranges can't tell you their positions on the page, so you'll have to use another technique to find out what bit of text was clicked.

I've never seen a complete solution to this in javascript. A few years ago I worked on one but I didn't come up with an answer I was happy with (some really hard edge cases). The approach I used was a horrible hack: insert spans into the text then use them to perform binary search until you find the smallest possible span containing the mouse click. Spans don't change the layout, so you can use the span's position_x/y properties to find out they contain the click.

E.g. suppose you have the following text in a node:

<p>Here is some paragraph text.</p>

We know the click was somewhere in this paragraph. Split the paragraph in half with a span:

<p><span>Here is some p</span>aragraph text.</p>

If the span contains the click coordinates, continue binary search in that half, otherwise search the second half.

This works great for single lines, but if the text spans multiple lines you have to first find line breaks, or the spans can overlap. You also have to work out what to do when the click wasn't on any text but was in the element --- past the end of the last line in a paragraph for example.

Since I worked on this browsers have got a lot faster. They're probably fast enough now to add s around each character, then around each two characters etc to create a binary tree which is easy to search. You could try this approach - it would make it much easier to work out which line you're working on.

TL;DR this is a really hard problem and if there is an answer, it might not be worth your time to come up with it.

like image 190
Stephen Nelson Avatar answered Nov 14 '22 23:11

Stephen Nelson


There are two ways of doing this, just like every WYSIWYG does.

First: - you give up because it is too hard and it will end up to be a browser killer;

Second: - you try to parse the text and put it in the exact place in a semitransparent textarea or div above the original, but here we have two problems:

1) how would you parse the dynamic chunks of data to get only the text and to be sure you map it over the exact position of the actual content

2) how would you solve the update to parse for every darn character you type or every action you do in the editor.

In the end this is just a "A brutal odyssey to the dark side of the DOM tree", but if you choose the second way, than the code from your post will work like a charm.

like image 41
khael Avatar answered Nov 14 '22 23:11

khael


Sorry for bumping an old thread, but I wanted to post this here in case anyone else stumbles across this question, as there is very little information on this. I just had to write a function that does this for an Outlook for web userscript, because they override the default drag-n-drop functionality and break it in the compose box. This is the solution I came up with:

function rangeFromCoord(x, y) {
    const closest = {
        offset: 0,
        xDistance: Infinity,
        yDistance: Infinity,
    };

    const {
        minOffset,
        maxOffset,
        element,
    } = (() => {
        const range = document.createRange();
        range.selectNodeContents(document.elementFromPoint(x, y));
        return {
            element: range.startContainer,
            minOffset: range.startOffset,
            maxOffset: range.endOffset,
        };
    })();

    for(let i = minOffset; i <= maxOffset; i++) {
        const range = document.createRange();
        range.setStart(element, i);
        range.setEnd(element, i);
        const marker = document.createElement("span");
        marker.style.width = "0";
        marker.style.height = "0";
        marker.style.position = "absolute";
        marker.style.overflow = "hidden";
        range.insertNode(marker);
        const rect = marker.getBoundingClientRect();
        const distX = Math.abs(x - rect.left);
        const distY = Math.abs(y - rect.top);
        marker.remove();
        if(closest.yDistance > distY) {
            closest.offset = i;
            closest.xDistance = distX;
            closest.yDistance = distY;
        } else if(closest.yDistance === distY) {
            if(closest.xDistance > distX) {
                closest.offset = i;
                closest.xDistance = distX;
                closest.yDistance = distY;
            }
        }
    }

    const range = document.createRange();
    range.setStart(element, closest.offset);
    range.setEnd(element, closest.offset);
    return range;
}

All you do is pass in the client coordinates, and the function will automatically select the most specific element at that position. It will use that selection to get the parent element used by the browser (most notably contenteditable elements), as well as the maximum and minimum offsets. It will then proceed, iterating through the offsets, placing marker span elements with position: absolute; width: 0; height: 0; overflow: hidden; at each offset to probe their position, removing them, and checking distance. As per most text editors, it will first get as close as it can on the Y coordinate, and then move in on the X coordinate. Once it finds the closest position, it will create a new selection and return it.

like image 1
sploders101 Avatar answered Nov 15 '22 01:11

sploders101