Tips for distinguishing the beginning and ending points of wrapped text in the Firefox browser

Within my work, I am utilizing a contentEditable span where I aim to position an element with position: absolute on the same line as the cursor. However, issues arise when text wrapping occurs - causing abnormal behavior at the start and end of wrapped lines.

In both instances, when the cursor is positioned at the start of the second line, the y offset from getBoundingClientRect() matches the offset of the first line. Yet, upon moving one space further into the second line, the y offset aligns correctly with that line.

The code snippet below showcases this behavior specifically in Firefox. While Chrome appears to handle it better, my complete implementation encounters imprecise behavior in Chrome as well, which I managed to resolve. In contrast, Firefox exhibits inconsistent behavior - the last position of the first line displays an offset equal to the first line, while the initial position on the second line mirrors the first line's offset, before functioning properly thereafter.

Explore the example by navigating to the last position on the first line. Notice how the value of CURRENT_TOP displayed in the console remains at 16. Upon shifting right once to move the cursor onto the next line, it still reads 16. Subsequently, moving right once more will display 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>

Answer №1

Begin with the diagnosis, then proceed to the treatment plan.

Diagnosis

This unusual behavior is a result of Chrome and Firefox interpreting newline wrapping differently. To observe this distinction, run the provided code snippet in both Chrome and Firefox. The additional console output includes:

anchorOffset: ${domSelection.anchorOffset}

We will analyze the outcomes below.

The browsers wrap text at varying positions, but the key observation lies in how they handle the newline transition. In Chrome, the cursor shifts immediately to the next line as if a space has been converted into a newline character (NL), akin to a Carriage Return plus Line Feed (CR+LF). Consequently, Chrome places the cursor on Line 2 post new line insertion.

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

Refer to the screenshot illustrating Chrome's behavior. Click here.

In contrast, Firefox moves the caret after the space before transitioning to the next line. While preserving the space, the added newline doesn't factor into the offset calculation. This results in Firefox recognizing the newline as part of Line 1 instead of Line 2.

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

Explore the sample image denoting Firefox's behavior. Access it here.

Treatment Approach

To address this issue, detecting the browser type through some workaround specific to Firefox becomes essential. An example includes:

const isFirefox = typeof InstallTrigger !== 'undefined';

Verified compatible with Firefox version 111.

An effective strategy involves marking whether we are within a Firefox-generated newline. Initially, establish some global variables:

// Tracking presence of Firefox-generated newline
let isNewline = false;
// Identifying Firefox as the current browser
const isFirefox = typeof InstallTrigger !== 'undefined';

Note that 'isNewline' could have broader application beyond Firefox-specific considerations. Subsequently, incorporate line-hopping for Firefox within the 'keyup' handler:

/*
* Ascertaining Firefox presence and proximity to line end.
* Extensible to other browsers as necessary.
*/
if(isFirefox && rect.y < nextCharacterRect.y)
{
    // Position just after space implies within newline sequence
    if(isNewline)
    {
        /*
        * Direct jump to the next line by simulating LF+CR.
        */
        domRange = newRange;
        domSelection.getRangeAt(0);
        rect = domRange.getBoundingClientRect();

        // End of Firefox's newline formation
        isNewline = false;
    }
    // Start of Firefox's newline sequence, marking space location
    else
        isNewline = true;
}

Potential extensions include direction detection for refined adjustments.

Collate all modifications in the ensuing code snippet. Notable changes involve declaring 'domRange' and 'rect' using 'let' instead of 'const'.

Final Thoughts

A more elegant solution might exist, yet the current approach fulfills its purpose. Essentially, adapting Firefox's line-wrapping by enforcing a Chrome-like newline structure ameliorates disparities. Despite minor differences like double keystrokes required in Firefox versus one in Chrome, equivalent behaviors ensue. Furthermore, this workaround easily adapts for addressing issues across various browsers.


Credit

Inspiration stemmed from a suggestion by @herrstrietzel, emphasizing the utility of a newline denotation variable outlined in this post. Considerations for selection direction and mouse interactions were also discussed.

Answer №2

According to @Krokomot, Firefox has a unique way of handling line wraps.

Interestingly, the end of the previous line and the beginning of the current line both return the same character index or anchorOffset value.

A workaround involves storing the last character index and the last top y value in global variables.

If the current character position and the previous top y value are equal to the previous values,

- we use the y position calculated from the next character (anchorOffset + 1) after the line break.

const textEl = document.getElementById("myText");
let bbText = textEl.getBoundingClientRect();
let textElTop = bbText.top;
let textElRight = bbText.right;

let lastCharPos = 0;
let lastTop = 0;

myText.addEventListener("click", (e) => {
  updateSelection(e);
});

document.addEventListener("keyup", (e) => {
  updateSelection(e);
});

function updateSelection(e) {
  let selection = window.getSelection();
  let caret = selection.getRangeAt(0);
  let range = document.createRange();
  let { anchorNode, anchorOffset } = selection;
  range.setStart(anchorNode, anchorOffset);

  // get y pos of next character
  let anchorOffset2 =
    anchorOffset < anchorNode.textContent.length - 1 ?
    anchorOffset + 1 :
    anchorOffset;

  let rangeN = document.createRange();
  rangeN.setStart(anchorNode, anchorOffset2);

  let bb = caret.getBoundingClientRect();
  let bb2 = rangeN.getBoundingClientRect();

  let height = bb.height;
  let top = bb.top - textElTop;
  let top2 = bb2.top - textElTop;



  // check mouse position on click
  let mouseX = e.pageX ? e.pageX : 0;
  let distX = mouseX ? Math.abs(bb.left - mouseX) : 0;
  let distX2 = mouseX ? Math.abs(bb2.left - mouseX) : 0;



  if (
    ((lastTop && lastTop == top && lastCharPos == anchorOffset) ||
      (lastTop && lastTop != top && lastCharPos < anchorOffset)
    ) ||
    (distX > distX2)
  ) {
    top = top2;
  }

  if (distX < distX2) {
    top = bb.top - textElTop;
  }

  // update
  lastCharPos = anchorOffset;
  lastTop = top;
  mouseX = 0;

  // shift line indicator
  selectionLine.setAttribute("style", `top:${top}px; height:${height}px;`);
  cursor.setAttribute("style", `top:${bb.top}px; left:${bb.left}px;`);


}
body {
  font-size: 2em;
  margin: 0em;
  padding: 11px;
}

* {
  box-sizing: border-box;
}

.wrap {
  position: relative;
  width: 300px;
}

.text-container {
  display: block;
  border: 1px solid black;
  line-height: 1.5em;
  padding: 1em;
  position: relative;
}

.text-container:focus+.selectionLine {
  border-left: 10px solid green;
  display: block;
  position: absolute;
  width: 0;
  height: 1em;
  top: 0;
  right: 0;
}

#cursor {
  position: absolute;
  width: 0.2em;
  height: 0.2em;
  top: 0;
  right: 0;
  background: red;
  border-radius: 50%;
}
<div id="info" style="position:absolute; right:0; top:0;"></div>
<div class="wrap">
  <div 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.
  </div>
  <div id="selectionLine" class="selectionLine"></div>
</div>

<div id="cursor"></div>

The examples above also address new caret positions based on mouse input.

However, this method still encounters issues with up and down arrow key navigation.

The red spot indicates the native caret position, which cannot be fixed, while the green bar represents the adjusted y offset.

Adding selection direction (forward or backwards)

We're also monitoring key inputs like "ArrowLeft" and "ArrowUp" to adjust the caret position accordingly.

This method may seem cumbersome, but it provides decent support for arrow-key navigation.

This example additionally includes Firefox user detection as advised by @Krokomot.

const textEl = document.getElementById("myText");
let bbText = textEl.getBoundingClientRect();
let textElTop = bbText.top;
let textElRight = bbText.right;

let lastCharPos = 0;
let lastTop = 0;
let forwards = true;

// simple firefox agent detection
const isFirefox = typeof InstallTrigger !== "undefined";

myText.addEventListener("click", (e) => {
  updateSelection(e);
});

document.addEventListener("keyup", (e) => {
  updateSelection(e);
});

function updateSelection(e) {
  let selection = window.getSelection();
  let caret = selection.getRangeAt(0);
  let range = document.createRange();
  let { anchorNode, anchorOffset } = selection;
  range.setStart(anchorNode, anchorOffset);

  let bb = caret.getBoundingClientRect();
  let height = bb.height;
  let top = bb.top - textElTop;

  if (isFirefox) {
    // get y pos of next character
    let anchorOffset2 =
      anchorOffset < anchorNode.textContent.length - 1
        ? anchorOffset + 1
        : anchorOffset;

    let anchorOffset3 = anchorOffset > 0 ? anchorOffset - 1 : anchorOffset;

    let rangeN = document.createRange();
    rangeN.setStart(anchorNode, anchorOffset2);

    let rangeP = document.createRange();
    rangeP.setStart(anchorNode, anchorOffset3);

    let bb2 = rangeN.getBoundingClientRect();
    let bb0 = rangeP.getBoundingClientRect();

    let top2 = bb2.top - textElTop;
    let top0 = bb0.top - textElTop;

    // check mouse position on click
    let mouseX = e.pageX ? e.pageX : 0;
    let mouseY = e.pageY ? e.pageY : 0;

    // check keybord inputs
    let key = e.key ? e.key : "";

    let distX = mouseX ? Math.abs(bb.left - mouseX) : 0;
    let distX2 = mouseX ? Math.abs(bb2.left - mouseX) : 0;

    let distY = mouseY ? Math.abs(bb.top - mouseY) : 0;
    let distY2 = mouseY ? Math.abs(bb2.top - mouseY) : 0;

    // direction: forward or backward
    if (
      lastCharPos > anchorOffset ||
      key === "ArrowLeft" ||
      key === "ArrowUp" ||
      (distY && distY < distY2)
    ) {
      forwards = false;
    } else if (
      lastCharPos < anchorOffset ||
      key === "ArrowRight" ||
      key === "ArrowDown" ||
      (distY && distY > distY2)
    ) {
      forwards = true;
    }

    // forwards
    if (
      forwards &&
      (lastCharPos == anchorOffset || distX > distX2 || key === "ArrowDown")
    ) {
      top = top2;
    }
    
    // backwards
    else {
      //console.log("back", lastCharPos, anchorOffset);
      if (lastCharPos > anchorOffset) {
        top = top2;
      }
    }

    // update
    lastCharPos = anchorOffset;
    lastTop = top;
  }

  // shift line indicator
  selectionLine.setAttribute("style", `top:${top}px; height:${height}px;`);
  cursor.setAttribute("style", `top:${bb.top}px; left:${bb.left}px;`);
}
body{
  font-size: 2em;
  margin:0em;
  padding:11px;
}

*{
  box-sizing:border-box;
}

.wrap{
  position: relative;
  width: 300px;

}

.text-container {
  display: block;
  border: 1px solid black;
  line-height: 1.5em;
  padding: 1em;
  position: relative;
}




.text-container:focus+
.selectionLine {
  border-left: 10px solid green;
  display:block;
  position: absolute;
  width:0;
  height:1em;
  top:0;
  right:0;

}

#cursor{
    position: absolute;
  width:0.2em;
  height:0.2em;
  top:0;
  right:0;
  background: red;
    border-radius: 50%;
}
<div id="info" style="position:absolute; right:0; top:0;"></div>
<div class="wrap">
<div 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. 
</div>
<div id="selectionLine" class="selectionLine"></div>
  </div>

<div id="cursor"></div>

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Can someone please explain how to use the prevState in REACT?

Can you explain the difference between how to define the counterHandler function in these two examples? counterHandler = () => { this.setState(() => { return { times: this.state.times + 1 } }); } versus counterHandle ...

Exploring design techniques in React Native

I am currently part of a team working on a react native project with multiple contributors. My goal is to find an effective way to segregate the styling tasks from coding, allowing UI developers to work independently without relying on code developers. In ...

Is there a way to eliminate the gap between two horizontal rule tags?

What causes the space between two <hr> tags to remain? Despite setting the width of the <hr> tags to 49%, there is still a gap between them. How can this space be removed from the <hr> tags? Shown below is the HTML and CSS code: *{mar ...

Tips for adjusting the background color of an ag-grid component in a React application

Looking to make a full background color change in ag-grid within a React application. Experimented with row cell style, but aiming for the entire grid background change. Here is my code snippet: { headerName: "Module Name", field: "ModuleName", ...

Is there a specific jest matcher available for comparing Array<Objects>?

I'm currently testing the equality of two arrays of objects and have found that the toEqual matcher in Jest only works for arrays of strings. Is there another matcher available in Jest that can handle this condition? Please refrain from marking this a ...

A guide on populating a dropdown menu with spring and hibernate in JSP

I'm a newcomer here and seeking ideas from you all. I have 4 dropdown lists and want to populate one select box based on the selection made in another select box using database values. I have already set up the database, but unsure how to proceed. Any ...

Reactjs implemented with Material UI and redux form framework, featuring a password toggle functionality without relying on hooks

Currently, I am working on a react project where I have developed a form framework that wraps Material-UI around Redux Form. If you want to check out the sandbox for this project, you can find it here: https://codesandbox.io/s/romantic-pasteur-nmw92 For ...

Synchronize JSON data with the Document Object Model (DOM

My current project is built using React, where I am rendering the page dynamically based on JSON data. The page consists of various component types, ranging from images to text content. Each component includes a delete option, allowing users to change im ...

The datetimepicker is not functioning properly

I'm experiencing an issue with the datetimepicker where it doesn't display a calendar when I click the icon. Interestingly, the Chrome browser isn't showing any errors in the development console. <script src="Scripts/jquery-2.1.1.min.js ...

Using CSS to break or wrap long words in text

I have a div that spans the width of the page. I'm looking to ensure that a very long word in this div breaks and wraps properly, so all the content stays within the screen width and doesn't overflow beyond 100%. Although I've attempted usi ...

Obtain latitude and longitude coordinates for the corners of a React Leaflet map

Currently, I am working with react-leaflet and facing a particular challenge: In my map application, I need to display pointers (latitude, longitude) from the database. However, retrieving all these pointers in one call could potentially cause issues due ...

Padding located within a div tag

As a novice in the world of HTML and CSS, I have designed a birthday card with a desired 50/50 split down the center. Placing an image on the left side was successful, however when trying to add text to the right, it ended up too close to the center line. ...

Display an error message when the button is clicked and the input field is left empty in a Vue 3 script setup

Hello, I am currently exploring Vue 3 and embarking on a new Vue 3 project venture. However, I seem to be encountering a challenge when it comes to displaying an error message if the button is clicked while the input field remains empty in my Vue 3 script ...

Emphasizing a full row within a table is a way to draw attention

I have a piece of HTML/CSS code that successfully underlines the text in a single table cell when hovering over it. However, I want to extend this effect to underline all text in a row of cells, rather than just one cell. It's not necessary for the en ...

Best method for simultaneously calling multiple routes in Node.js and Express

What is the best approach for handling multiple routes simultaneously in Node.js and Express? For instance, if I have two routes defined as app.get('/', routes.views.index); and app.all('/header', routes.views.header); I want both route ...

Difficulties encountered when trying to interact with buttons using JS and Bootstrap

I'm working with a Bootstrap 5 button in my project and I want to access it using JavaScript to disable it through the attribute. Here is the code I've been testing: Script in Header: ` <script> console.log(document.getElementsByName(& ...

Guide to continuously play the animation, Version 2

I have been struggling with an animation that I need to loop indefinitely. The problem arises when trying to use animation-iteration-count: infinite. It seems like no matter where I place it in the code, whether within the keyframes or normal CSS rules, it ...

Exploring the possibilities of event-driven programming with Java and Javascript?

When performing computations on a server, the client inputs data that is captured through Javascript. A XMLHttpRequest is made to send this data to the server for processing. What happens if the computation takes an hour and the client leaves or switches o ...

Is there a way to create a dynamic CSS for a custom JSF component through code generation?

I am currently developing a custom JSF component that displays content in multiple columns in a "responsive" manner. This component utilizes the "CSS Multi-column Layout Module" (http://www.w3.org/TR/css3-multicol/) (tutorial available in French: ). Belo ...

HTML/CSS: There is a sentence that has no spaces that is exiting outside a div

HTML: <div> bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb </div> CSS: div { width: 150px; border: 1px solid blue } DEMO: http://example.com QUESTION: How can we solve this issue? ...