I am trying to add an ellipsis to the mid-point in a string with the following complications:
I don't know how long the string is going to be
I only know the max-width and min-width of the parent element
The string may or may not fit into it's parent and not require ellipses
I have a plunk here to illustrate it. The script only assumes one instance, but you should get the idea:
(function(){
// variables
var parent = document.querySelectorAll(".wrapper")[0],
parentWidth = parent.clientWidth,x = 0, elem, hellip
txtStr = document.querySelector("#shorten"),
strWidth = txtStr.clientWidth,
strTxt = txtStr.innerText,
ending = document.createElement("span"),
endTxt = strTxt.slice(Math.max(strTxt.length - (strTxt.length / 4))) || endTxt;
txtStr.style.overflow = "hidden"
txtStr.style.textOverflow = "ellipsis"
ending.appendChild(document.createTextNode(endTxt))
ending.classList.add("ellipsis")
document.querySelectorAll(".wrapper")[0].appendChild(ending)
var ell = function(a, b){
if (a <= b){
ending.classList.add("visible")
}
else {
ending.classList.remove("visible")
}
}
ell(parentWidth, strWidth) // We need to display any changes immediately
window.onresize = function(){ // if the window is resized, we also need to display changes
hellip = document.querySelectorAll(".ellipsis")[0].clientWidth
parentWidth = parent.clientWidth
// the use of 'calc()' is because the length of string in px is never known
txtStr.style.width = "calc(100% - " + hellip + "px"
ell(parentWidth, strWidth)
}
})();
It's a bit clunky, but demonstrates the idea.
The issue I am having in React 16, is that the string is not rendered at the point I need to measure it to create the bit of text at the end. Therefore, when the new node is created it has no dimensions and cannot be measured as it doesn't exist in the DOM.
The functionality works - sort of as the screen resizes, but that's beside the point. I need to get it to do the do at render time.
The actual app is proprietary, and I cannot share any of my code from that in this forum.
EDIT: Another thing to bare in mind (teaching to suck eggs, here) is that in the example, the script is loaded only after the DOM is rendered, so all of the information required is already there and measurable.
Thank you to all that looked at this, but I managed to figure out the problem.
Once I worked out the finesse of the lifecycle, it was actually still quite tricky. The issue being measuring the original string of text. Looking back now, it seems insignificant.
Essentially, I pass a few elements into the component as props: an id, any required padding, the length of the ending text required for context and the text (children).
Once they are in, I need to wait until it is mounted until I can do anything as it all depends on the DOM being rendered before anything can be measured. Therefore, componentDidMount() and componentDidUpdate() are the stages I was interested in. componentWillUnmount() is used to remove the associated event listener which in this instance is a resize event.
Once mounted, I can get the bits required for measuring: the element and importantly, its parent.
getElements(){
return {
parent: this.ellipsis.offsetParent,
string: this.props.children
}
}
Then, I need to make sure that I can actually measure the element so implement some inline styles to allow for that:
prepareParentForMeasure(){
if(this.getElements().parent != null){
this.getElements().parent.style.opacity = 0.001
this.getElements().parent.style.overflow = 'visible'
this.getElements().parent.style.width = 'auto'
}
}
As soon as I have those measurements, I removed the styles.
At this point, the script will partially work if I carry on down the same path. However, adding an additional element to work as a guide is the kicker.
The returned element is split into three elements (span tags), each with a different purpose. There is the main bit of text, or this.props.children, if you like. This is always available and is never altered. The next is the tail of the text, the 'n' number of characters at the end of the string that are used to contextually display the end of the string - this is given a class of 'ellipsis', although the ellipsis is actually added to the original and first element. The third is essentially exactly the same as the first, but is hidden and uninteractable, although it does have dimensions. This is because the first two - when rendered - have different widths and cannot be relied upon as both contribute to the width of the element, whereas the third doesn't.
<Fragment>
<span className='text'>{this.props.children}</span>
<span className='ellipsis'>{this.tail()}</span>
<span className='text guide' ref={node => this.ellipsis = node}>
{this.props.children}</span>
</Fragment>
These are in a fragment so as to not require a surrounding element.
So, I have the width of the surrounding parent and I have the width of the text element (in the third span). which means that if I find that the text string is wider than the surrounding wrapper, I add a class to the ellipsis span of 'visible', and one to the 'text' element of 'trimmed', I get an ellipsis in the middle of the string and I use the resize event to make sure that if someone does do that, all measurements are re-done and stuff is recalculated and rendered accordingly.
Related
I'm trying to write a JS function that would give empty heading elements (h1, and h2...)a “role” attribute value of “presentation”.
This is my first time working with accessibility in my projects and would love some help!
If they're empty, do they need to be there at all? The most correct thing is just to remove them.
However, you can use document.querySelectorAll() to get all the headings, then look inside each one to see whether they are empty. If they are, you can set the role attribute. The following code is very quick and dirty, but will get you some of the way.
var headings = document.querySelectorAll("h1,h2,h3,h4,h5,h6");
// iterate through each heading
Array.prototype.forEach.call (headings, function (node) {
// remove all white space
var theTextContent = node.textContent.replace(/\s/g,'');
// see if there's anything left in the string
if (theTextContent.length < 1) {
// node contains no visible text, mark it as presentation
node.setAttribute("role", "presentation");
}
} );
BUT this is a risky heuristic. Some headings might not contain text nodes, yet still appear as text on screen (e.g. they may have a background image in CSS representing a text in bitmap form). Instead of adding role="presentation" to these, you absolutely should add an aria-label with the correct heading text, otherwise you'll be violating at least two WCAG success criteria. ("Images of Text" and "Headings and Labels").
If you were using style attributes it might look something like this:
<h1 aria-label="welcome" style="background:url(welcome.png);"></h1>
I have a contenteditable div as follow (| = cursor position):
<div id="mydiv" contenteditable="true">lorem ipsum <spanclass="highlight">indol|or sit</span> amet consectetur <span class='tag'>adipiscing</span> elit</div>
I would like to get the current cursor position including html tags. My code :
var offset = document.getSelection().focusOffset;
Offset is returning 5 (full text from the last tag) but i need it to handle html tags. The expected return value is 40. The code has to work with all recents browsers.
(i also checked this : window.getSelection() offset with HTML tags? but it doesn't answer my question).
Any ideas ?
Another way to do it is by adding a temporary marker in the DOM and calculating the offset from this marker. The algorithm looks for the HTML serialization of the marker (its outerHTML) within the inner serialization (the innerHTML) of the div of interest. Repeated text is not a problem with this solution.
For this to work, the marker's serialization must be unique within its div. You cannot control what users type into a field but you can control what you put into the DOM so this should not be difficult to achieve. In my example, the marker is made unique statically: by choosing a class name unlikely to cause a clash ahead of time. It would also be possible to do it dynamically, by checking the DOM and changing the class until it is unique.
I have a fiddle for it (derived from Alvaro Montoro's own fiddle). The main part is:
function getOffset() {
if ($("." + unique).length)
throw new Error("marker present in document; or the unique class is not unique");
// We could also use rangy.getSelection() but there's no reason here to do this.
var sel = document.getSelection();
if (!sel.rangeCount)
return; // No ranges.
if (!sel.isCollapsed)
return; // We work only with collapsed selections.
if (sel.rangeCount > 1)
throw new Error("can't handle multiple ranges");
var range = sel.getRangeAt(0);
var saved = rangy.serializeSelection();
// See comment below.
$mydiv[0].normalize();
range.insertNode($marker[0]);
var offset = $mydiv.html().indexOf($marker[0].outerHTML);
$marker.remove();
// Normalizing before and after ensures that the DOM is in the same shape before
// and after the insertion and removal of the marker.
$mydiv[0].normalize();
rangy.deserializeSelection(saved);
return offset;
}
As you can see, the code has to compensate for the addition and removal of the marker into the DOM because this causes the current selection to get lost:
Rangy is used to save the selection and restore it afterwards. Note that the save and restore could be done with something lighter than Rangy but I did not want to load the answer with minutia. If you decide to use Rangy for this task, please read the documentation because it is possible to optimize the serialization and deserialization.
For Rangy to work, the DOM must be in exactly the same state before and after the save. This is why normalize() is called before we add the marker and after we remove it. What this does is merge immediately adjacent text nodes into a single text node. The issue is that adding a marker to the DOM can cause a text node to be broken into two new text nodes. This causes the selection to be lost and, if not undone with a normalization, would cause Rangy to be unable to restore the selection. Again, something lighter than calling normalize could do the trick but I did not want to load the answer with minutia.
EDIT: This is an old answer that doesn't work for OP's requirement of having nodes with the same text. But it's cleaner and lighter if you don't have that requirement.
Here is one option that you can use and that works in all major browsers:
Get the offset of the caret within its node (document.getSelection().anchorOffset)
Get the text of the node in which the caret is located (document.getSelection().anchorNode.data)
Get the offset of that text within #mydiv by using indexOf()
Add the values obtained in 1 and 3, to get the offset of the caret within the div.
The code would look like this for your particular case:
var offset = document.getSelection().anchorOffset;
var text = document.getSelection().anchorNode.data;
var textOffset = $("#mydiv").html().indexOf( text );
offsetCaret = textOffset + offset;
You can see a working demo on this JSFiddle (view the console to see the results).
And a more generic version of the function (that allows to pass the div as a parameter, so it can be used with different contenteditable) on this other JSFiddle:
function getCaretHTMLOffset(obj) {
var offset = document.getSelection().anchorOffset;
var text = document.getSelection().anchorNode.data;
var textOffset = obj.innerHTML.indexOf( text );
return textOffset + offset;
}
About this answer
It will work in all recent browsers as requested (tested on Chrome 42, Firefox 37, and Explorer 11).
It is short and light, and doesn't require any external library (not even jQuery)
Issue: If you have different nodes with the same text, it may return the offset of the first occurrence instead of the real position of the caret.
NOTE: This solution works even in nodes with repeated text, but it detects html entities (e.g.: ) as only one character.
I came up with a completely different solution based on processing the nodes. It is not as clean as the old answer (see other answer), but it works fine even when there are nodes with the same text (OP's requirement).
This is a description of how it works:
Create a stack with all the parent elements of the node in which the caret is located.
While the stack is not empty, traverse the nodes of the containing element (initially the content editable div).
If the node is not the same one at the top of the stack, add its size to the offset.
If the node is the same as the one at the top of the stack: pop it from the stack, go to step 2.
The code is like this:
function getCaretOffset(contentEditableDiv) {
// read the node in which the caret is and store it in a stack
var aux = document.getSelection().anchorNode;
var stack = [ aux ];
// add the parents to the stack until we get to the content editable div
while ($(aux).parent()[0] != contentEditableDiv) { aux = $(aux).parent()[0]; stack.push(aux); }
// traverse the contents of the editable div until we reach the one with the caret
var offset = 0;
var currObj = contentEditableDiv;
var children = $(currObj).contents();
while (stack.length) {
// add the lengths of the previous "siblings" to the offset
for (var x = 0; x < children.length; x++) {
if (children[x] == stack[stack.length-1]) {
// if the node is not a text node, then add the size of the opening tag
if (children[x].nodeType != 3) { offset += $(children[x])[0].outerHTML.indexOf(">") + 1; }
break;
} else {
if (children[x].nodeType == 3) {
// if it's a text node, add it's size to the offset
offset += children[x].length;
} else {
// if it's a tag node, add it's size + the size of the tags
offset += $(children[x])[0].outerHTML.length;
}
}
}
// move to a more inner container
currObj = stack.pop();
children = $(currObj).contents();
}
// finally add the offset within the last node
offset += document.getSelection().anchorOffset;
return offset;
}
You can see a working demo on this JSFiddle.
About this answer:
It works in all major browsers.
It is light and doesn't require external libraries (apart from jQuery)
It has an issue: html entities like are counted as one character only.
I have a contenteditable div as follow (| = cursor position):
<div id="mydiv" contenteditable="true">lorem ipsum <spanclass="highlight">indol|or sit</span> amet consectetur <span class='tag'>adipiscing</span> elit</div>
I would like to get the current cursor position including html tags. My code :
var offset = document.getSelection().focusOffset;
Offset is returning 5 (full text from the last tag) but i need it to handle html tags. The expected return value is 40. The code has to work with all recents browsers.
(i also checked this : window.getSelection() offset with HTML tags? but it doesn't answer my question).
Any ideas ?
Another way to do it is by adding a temporary marker in the DOM and calculating the offset from this marker. The algorithm looks for the HTML serialization of the marker (its outerHTML) within the inner serialization (the innerHTML) of the div of interest. Repeated text is not a problem with this solution.
For this to work, the marker's serialization must be unique within its div. You cannot control what users type into a field but you can control what you put into the DOM so this should not be difficult to achieve. In my example, the marker is made unique statically: by choosing a class name unlikely to cause a clash ahead of time. It would also be possible to do it dynamically, by checking the DOM and changing the class until it is unique.
I have a fiddle for it (derived from Alvaro Montoro's own fiddle). The main part is:
function getOffset() {
if ($("." + unique).length)
throw new Error("marker present in document; or the unique class is not unique");
// We could also use rangy.getSelection() but there's no reason here to do this.
var sel = document.getSelection();
if (!sel.rangeCount)
return; // No ranges.
if (!sel.isCollapsed)
return; // We work only with collapsed selections.
if (sel.rangeCount > 1)
throw new Error("can't handle multiple ranges");
var range = sel.getRangeAt(0);
var saved = rangy.serializeSelection();
// See comment below.
$mydiv[0].normalize();
range.insertNode($marker[0]);
var offset = $mydiv.html().indexOf($marker[0].outerHTML);
$marker.remove();
// Normalizing before and after ensures that the DOM is in the same shape before
// and after the insertion and removal of the marker.
$mydiv[0].normalize();
rangy.deserializeSelection(saved);
return offset;
}
As you can see, the code has to compensate for the addition and removal of the marker into the DOM because this causes the current selection to get lost:
Rangy is used to save the selection and restore it afterwards. Note that the save and restore could be done with something lighter than Rangy but I did not want to load the answer with minutia. If you decide to use Rangy for this task, please read the documentation because it is possible to optimize the serialization and deserialization.
For Rangy to work, the DOM must be in exactly the same state before and after the save. This is why normalize() is called before we add the marker and after we remove it. What this does is merge immediately adjacent text nodes into a single text node. The issue is that adding a marker to the DOM can cause a text node to be broken into two new text nodes. This causes the selection to be lost and, if not undone with a normalization, would cause Rangy to be unable to restore the selection. Again, something lighter than calling normalize could do the trick but I did not want to load the answer with minutia.
EDIT: This is an old answer that doesn't work for OP's requirement of having nodes with the same text. But it's cleaner and lighter if you don't have that requirement.
Here is one option that you can use and that works in all major browsers:
Get the offset of the caret within its node (document.getSelection().anchorOffset)
Get the text of the node in which the caret is located (document.getSelection().anchorNode.data)
Get the offset of that text within #mydiv by using indexOf()
Add the values obtained in 1 and 3, to get the offset of the caret within the div.
The code would look like this for your particular case:
var offset = document.getSelection().anchorOffset;
var text = document.getSelection().anchorNode.data;
var textOffset = $("#mydiv").html().indexOf( text );
offsetCaret = textOffset + offset;
You can see a working demo on this JSFiddle (view the console to see the results).
And a more generic version of the function (that allows to pass the div as a parameter, so it can be used with different contenteditable) on this other JSFiddle:
function getCaretHTMLOffset(obj) {
var offset = document.getSelection().anchorOffset;
var text = document.getSelection().anchorNode.data;
var textOffset = obj.innerHTML.indexOf( text );
return textOffset + offset;
}
About this answer
It will work in all recent browsers as requested (tested on Chrome 42, Firefox 37, and Explorer 11).
It is short and light, and doesn't require any external library (not even jQuery)
Issue: If you have different nodes with the same text, it may return the offset of the first occurrence instead of the real position of the caret.
NOTE: This solution works even in nodes with repeated text, but it detects html entities (e.g.: ) as only one character.
I came up with a completely different solution based on processing the nodes. It is not as clean as the old answer (see other answer), but it works fine even when there are nodes with the same text (OP's requirement).
This is a description of how it works:
Create a stack with all the parent elements of the node in which the caret is located.
While the stack is not empty, traverse the nodes of the containing element (initially the content editable div).
If the node is not the same one at the top of the stack, add its size to the offset.
If the node is the same as the one at the top of the stack: pop it from the stack, go to step 2.
The code is like this:
function getCaretOffset(contentEditableDiv) {
// read the node in which the caret is and store it in a stack
var aux = document.getSelection().anchorNode;
var stack = [ aux ];
// add the parents to the stack until we get to the content editable div
while ($(aux).parent()[0] != contentEditableDiv) { aux = $(aux).parent()[0]; stack.push(aux); }
// traverse the contents of the editable div until we reach the one with the caret
var offset = 0;
var currObj = contentEditableDiv;
var children = $(currObj).contents();
while (stack.length) {
// add the lengths of the previous "siblings" to the offset
for (var x = 0; x < children.length; x++) {
if (children[x] == stack[stack.length-1]) {
// if the node is not a text node, then add the size of the opening tag
if (children[x].nodeType != 3) { offset += $(children[x])[0].outerHTML.indexOf(">") + 1; }
break;
} else {
if (children[x].nodeType == 3) {
// if it's a text node, add it's size to the offset
offset += children[x].length;
} else {
// if it's a tag node, add it's size + the size of the tags
offset += $(children[x])[0].outerHTML.length;
}
}
}
// move to a more inner container
currObj = stack.pop();
children = $(currObj).contents();
}
// finally add the offset within the last node
offset += document.getSelection().anchorOffset;
return offset;
}
You can see a working demo on this JSFiddle.
About this answer:
It works in all major browsers.
It is light and doesn't require external libraries (apart from jQuery)
It has an issue: html entities like are counted as one character only.
Is it possible to compute resulting css style on the element manually (without need to render it)?
Lets say I'm supposed to have an HTML structure:
<p style="some_style1">
<span style="some_style2">
<span style="some_style3">
TEXT
</span>
</span>
</p>
I know what are some_style1, some_style2, some_style3 in terms of JS object (for example i have data for each element like: {font: 'Times New Roman' 12px bold; text-align: center;})
I want to MANUALLY (without need to render in browser the whole structure) compute resulting style that will effect "TEXT".
What algorithm (or solution) should I use?
There exist browsers that don't need rendering in a window (headless browser). You can load a page and query what you want. It won't be easier than in a normal browser to obtain what you ask though.
JSCSSP is a CSS parser written in cross-browser JavaScript that could be a first step to achieve what you want from scratch or quite. Give it a stylesheet and it'll tell you what a browser would've parsed. You still must manage:
the DOM,
inheritance of styles,
determine which rules apply to a given element with or without class, id, attributes, siblings, etc
priorities of selectors
etc
Its author is D. Glazman, co-chairman of the W3C CSS group and developer of Kompozer, NVu and BlueGriffon so it should parse CSS as expected :)
The simplest thing I can think of is to wrap the whole thing in a a container that you set display: none on, and append it to the DOM. The browser won't render it, but you'll then be able to query the computed style.
Here's an example showing how jQuery can't find the style information when the structure isn't connected to the DOM, but when it is, it can:
jQuery(function($) {
// Disconnected structure
var x = $("<p style='color: red'><span style='padding: 2em'><span style='background-color: white'>TEXT</span></span></p>");
// Get the span
var y = x.find("span span");
// Show its computed color; will be blank
display("y.css('color'): " + y.css('color'));
// Create a hidden div and append the structure
var d = $("<div>");
d.hide();
d.append(x);
d.appendTo(document.body);
// Show the computed color now; show red
display("y.css('color'): " + y.css('color'));
// Detach it again
d.detach();
function display(msg) {
$("<p>").html(String(msg)).appendTo(document.body);
}
});
Live copy | source
I can't guarantee all values will be exactly right, you'll have to try it and see; browsers may defer calculating some things until/unless the container is visible. If you find that some properties you want aren't calculated yet, you may have to make the div visible, but off-page (position: absolute; left: -10000px);
I found some articles about this: Can jQuery get all styles applied to an element on Stackoverflow.
Also this one on quirksmode: Get Styles that shows the following function:
function getStyle(el,styleProp)
{
var x = document.getElementById(el);
if (x.currentStyle)
var y = x.currentStyle[styleProp];
else if (window.getComputedStyle)
var y = document.defaultView.getComputedStyle(x,null).getPropertyValue(styleProp);
return y;
}
This allows you to query for style properties
Styles override each other in the order in which they're defined: So anything in some_style3 that overrides the same selector in some_style2, say, will do. Otherwise, it will just be a union of the sets of selectors.
EDIT Some selectors won't override, but instead act relatively on a previous definition, so you've got to be careful about that.
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.
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.
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.
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.
I was working on a similar task to allow TinyMCE (inline mode) to initialize with a caret placed in mouse click position. The following code works in the latest Firefox and Chrome, at least:
let contentElem = $('#editorContentRootElem');
let editorConfig = { inline: true, forced_root_block: false };
let onFirstFocus = () => {
contentElem.off('click focus', onFirstFocus);
setTimeout(() => {
let uniqueId = 'uniqueCaretId';
let range = document.getSelection().getRangeAt(0);
let caret = document.createElement("span");
range.surroundContents(caret);
caret.outerHTML = `<span id="${uniqueId}" contenteditable="false"></span>`;
editorConfig.setup = (editor) => {
this.editor = editor;
editor.on('init', () => {
var caret = $('#' + uniqueId)[0];
if (!caret) return;
editor.selection.select(caret);
editor.selection.collapse(false);
caret.parentNode.removeChild(caret);
});
};
tinymce.init(editorConfig);
}, 0); // after redraw
}; // onFirstFocus
contentElem.on('click focus', onFirstFocus);
Explanation
It seems that after mouse click/focus event and redraw (setTimeout ms 0) document.getSelection().getRangeAt(0) returns valid cursor range. We can use it for any purpose. TinyMCE moves caret to start on initialization, so I create special span 'caret' element at current range start and later force editor to select it, then remove it.