Issue
I am working with a contentEditable span
where I want to place a position: absolute
element on the same line as the cursor. The problem happens when the text gets wrapped because it doesn't fit - the first and last position of the wrapped lines have weird behaviours.
For both of them, when I am at the first position in the second line the y
offset of getBoundingClientRect()
is equal to offset of the first line, however if I move one place further on second line the y offset
is correctly matching the second line.
In the snippet below this behaviour is displayed for Firefox. For Chrome it seems to work fine although in my full implementation it also has imprecise behavior, but I was able to solve it for chrome. However for Firefox the last position of first line has offset
equal to the first line, the first position on second line has offset
equal to the first line, afterwards it works fine.
In the example, go to the last place on first line and notice the CURRENT_TOP
value in the console says 16
. If you go one place right so the cursor is already on next line, it still says 16
. if you move one more right, it will say 36
const textEl = document.getElementById("myText")
textEl.addEventListener("keyup", (event) => {
const domSelection = window.getSelection();
if (domSelection && domSelection.isCollapsed && domSelection.anchorNode) {
let offsetNewLine = 0;
const domRange = domSelection.getRangeAt(0);
const rect = domRange.getBoundingClientRect();
const rects = domRange.getClientRects();
const newRange = document.createRange();
const newRangeNextOffset = domSelection.anchorNode.textContent.length < domSelection.anchorOffset + 1 ? domSelection.anchorOffset : domSelection.anchorOffset + 1
newRange.setStart(domSelection.anchorNode, newRangeNextOffset);
newRange.setEnd(domSelection.anchorNode, newRangeNextOffset);
const nextCharacterRect = newRange.getBoundingClientRect();
console.log(`CURRENT_TOP: ${rect.y}, NEXT_CHAR_TOP: ${nextCharacterRect.y}`);
}
})
.text-container {
width: 500px;
display: inline-block;
border: 1px solid black;
line-height: 20px;
padding: 5px;
}
<span id="myText" class="text-container" contentEditable="true">Go on the last position in the first row and come it to first position in the second row</span>
Solution
First the diagnosis, then the treatment.
Diagnosis
This strange behavior occurs thanks to the fact, that Chrome and Firefox seemingly treat the wrap-newline differently. Execute the following snippet in Chrome and Firefox. The only difference is, that I added
anchorOffset: ${domSelection.anchorOffset}
to the console output. We'll discuss the results below.
const textEl = document.getElementById("myText")
textEl.addEventListener("keyup", (event) => {
const domSelection = window.getSelection();
if (domSelection && domSelection.isCollapsed && domSelection.anchorNode) {
let offsetNewLine = 0;
let domRange = domSelection.getRangeAt(0);
let rect = domRange.getBoundingClientRect();
const rects = domRange.getClientRects();
const newRange = document.createRange();
const newRangeNextOffset = domSelection.anchorNode.textContent.length < domSelection.anchorOffset + 1 ? domSelection.anchorOffset : domSelection.anchorOffset + 1
newRange.setStart(domSelection.anchorNode, newRangeNextOffset);
newRange.setEnd(domSelection.anchorNode, newRangeNextOffset);
let nextCharacterRect = newRange.getBoundingClientRect();
console.log(`anchorOffset: ${domSelection.anchorOffset}, CURRENT_TOP: ${rect.y}, NEXT_CHAR_TOP: ${nextCharacterRect.y}`);
}
})
.text-container {
width: 500px;
display: inline-block;
border: 1px solid black;
line-height: 20px;
padding: 5px;
}
<span id="myText" class="text-container" contentEditable="true">Go on the last position in the first row and come it to first position in the second row</span>
The browsers wrap at different positions here, but that's not the point. Look at the output in Chrome first. Note, that the caret jumps directly to the next line, the actually existing space has been transformed to a newline (NL), and seemingly in the classical Carriage Return plus Line Feed (CR+LF) form. So after the NL Chrome sees the cursor, like the human eye, already on Line 2.
last non-whitespace at line 1 | wrapping-newline | first non-whitespace at line 2 |
---|---|---|
't' at offset 61 | NL at offset 62 | 'p' at offset 63 |
Now Firefox. The caret follows the space and then jumps to the next line. The space (SP) has been preserved. However the inserted newline has not been included into the offset-calculation. Further, it is still treated as part of line 1, i.e. the human eye sees the cursor on line 2, but Firefox on line 1. Whyever.
So Firefox iterates twice at the end of line 1 (SP then NL), but increments the offset only once (for SP and NL together), and without having really moved to line 2 yet. All that makes things so messy here.
last non-whitespace at line 1 | wrapping-newline | first non-whitespace at line 2 |
---|---|---|
'n' at offset 73 | SP and NL, both at offset 74 | 't' at offset 75 |
Treatment
The only way I currently can think of is to detect the browser and introduce a Firefox-specific workaround, so to check on Firefox e.g. with
const isFirefox = typeof InstallTrigger !== 'undefined';
Tested, and still works, with Firefox 111.
So, we could bridge the problem by denoting whether we are in a Firefox-newline. Let us add some globals first:
// whether we're in a (Firefox-)NL
let isNewline = false;
// whether we're in Firefox
const isFirefox = typeof InstallTrigger !== 'undefined';
Note, that one can use isNewline
also for other browsers if necessary. Next we add the Firefox-specific line-hopping into the keyup
handler:
/*
* Check whether we're in Firefox and on the edge of a line.
* At need easily extendable for other browsers.
*/
if(isFirefox && rect.y < nextCharacterRect.y)
{
// caret is after the SP, i.e. we're in the NL-sequence
if(isNewline)
{
/*
* Hop straight to the next line by
* de facto enforcing a LF+CR.
*/
domRange = newRange;
domSelection.getRangeAt(0);
rect = domRange.getBoundingClientRect();
// end of Firefox' NL-sequence
isNewline = false;
}
// begin of Firefox' NL-sequence, i.e. we hit the SP
else
isNewline = true;
}
This may be extended e.g. by selection direction detection for fine-tuning.
Lets put everything together in the following snippet. Note, that domRange
resp. rect
became let
instead of const
.
// our denotation values, see above
let isNewline = false;
const isFirefox = typeof InstallTrigger !== 'undefined';
const textEl = document.getElementById("myText")
textEl.addEventListener("keyup", (event) => {
const domSelection = window.getSelection();
if (domSelection && domSelection.isCollapsed && domSelection.anchorNode) {
let offsetNewLine = 0;
let domRange = domSelection.getRangeAt(0);
let rect = domRange.getBoundingClientRect();
const rects = domRange.getClientRects();
const newRange = document.createRange();
const newRangeNextOffset = domSelection.anchorNode.textContent.length < domSelection.anchorOffset + 1 ? domSelection.anchorOffset : domSelection.anchorOffset + 1
newRange.setStart(domSelection.anchorNode, newRangeNextOffset);
newRange.setEnd(domSelection.anchorNode, newRangeNextOffset);
const nextCharacterRect = newRange.getBoundingClientRect();
// the line-hopping, see above
if(isFirefox && rect.y < nextCharacterRect.y)
{
if(isNewline)
{
domRange = newRange;
domSelection.getRangeAt(0);
rect = domRange.getBoundingClientRect();
isNewline = false;
}
else
isNewline = true;
}
console.log(`anchorOffset: ${domSelection.anchorOffset}, CURRENT_TOP: ${rect.y}, NEXT_CHAR_TOP: ${nextCharacterRect.y}`);
}
})
.text-container {
width: 500px;
display: inline-block;
border: 1px solid black;
line-height: 20px;
padding: 5px;
}
<span id="myText" class="text-container" contentEditable="true">Go on the last position in the first row and come it to first position in the second row</span>
Conclusion
There may be a more elegant and sophisticated solution, but for now it does the job. Essentially we're modding Firefox' line-wrapping behavior by enforcing a LF+CR
type of newline à la Chrome. The only remaining difference is the additional space at the end of line before the actual wrapping, i.e. in Firefox we still have to press the key twice to get to the next line, instead of once as in Chrome. But that's pretty irrelevant here. Otherwise the behavior of both browsers is now equivalent. Furthermore, this workaround can easily be adapted for other browsers, if necessary.
Acknowledgement
The final spark of inspiration for using a newline-denotation variable came through a post by @herrstrietzel, where also approaches to consider the selection direction and mouse interaction are discussed.
Answered By - Krokomot
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.