skip to Main Content

I want to be able to highlight matches in user editable text, so I decided to do the standard exercise of creating a <div> that floats above the textarea and highlight the text in that div.

I got it almost perfect in about 30 minutes, so I am almost happy. The problem is, when you scroll all the way to right/bottom, the alignment breaks.

Try it, it’s quite neat except for that little bug. Once you scroll the textarea all the way down, the alignment between the text in the textarea and my overlay div breaks.

class TextareaOverlay {
    /**
     * 
     * @param {HTMLDivElement} container 
     */
    constructor(container) {
        /** @type {HTMLTextAreaElement} */
        this.textarea = container.querySelector("textarea");
        /** @type {HTMLDivElement} **/
        this.overlay = document.createElement("div");
        this.overlay.classList.add("overlay");
        container.appendChild(this.overlay);

        this.textarea.addEventListener("input", this.inputHandler.bind(this));
        this.textarea.addEventListener("scroll", this.scrollHandler.bind(this));
        this.inputHandler();
        this.sizeHandler();
        this.scrollHandler();
        this.resizeObserver = new ResizeObserver(this.sizeHandler.bind(this));
        this.resizeObserver.observe(this.textarea);
    }
    inputHandler() {
        const text = this.textarea.value;
        this.overlay.textContent = text;
    }
    scrollHandler() {
        this.overlay.scrollTop = this.textarea.scrollTop;
        this.overlay.scrollLeft = this.textarea.scrollLeft;
    }
    sizeHandler() {
        const rect = this.textarea.getBoundingClientRect();
        this.overlay.style.width = rect.width+"px";
        this.overlay.style.height = rect.height+"px";
    }
};
const textareaHelper = new TextareaOverlay(document.querySelector(".textarea-overlay"));
div.textarea-overlay {
    position: relative;
}
div.textarea-overlay textarea {
    width: 100%;
    height: 100%;
    padding: 10px;
    font-family: 'Courier New', Courier, monospace;
    background-color: #f8f9fa;
    margin: 0;
    box-sizing: border-box;
    overflow: scroll;
    white-space: pre;
    font-size: 1em;
}
div.textarea-overlay div.overlay {
    margin: 0;
    position: absolute;
    font-size: 1em;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    padding: 10px;
    white-space: pre;
    font-family: 'Courier New', Courier, monospace;
    pointer-events: none;
    overflow: hidden;
    box-sizing: border-box;
    border: 1px solid transparent;
}
<div class="textarea-overlay" style="width: 400px; height: 500px">
<textarea>
div.textarea-overlay {
    position: relative;
}
div.textarea-overlay textarea {
    width: 100%;
    height: 100%;
    padding: 10px;
    font-family: 'Courier New', Courier, monospace;
    background-color: #f8f9fa;
    margin: 0;
    box-sizing: border-box;
    overflow: scroll;
    white-space: pre;
    font-size: 1em;
}
div.textarea-overlay div.overlay {
    margin: 0;
    position: absolute;
    font-size: 1em;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    padding: 10px;
    white-space: pre;
    font-family: 'Courier New', Courier, monospace;
    pointer-events: none;
    overflow: hidden;
    box-sizing: border-box;
    border: 1px solid transparent;
}
</textarea>
</div>

So the question is how to deal with the scrolling issue. Bonus points if you also know how would I go about displaying text cursor in the overlay in case I wanted to hide the original text completely.

2

Answers


  1. There are 2 minor problems.

    (1) The code sets the dimensions of the overlay using getBoundingClientRect. However, this makes the overlay about 18px larger than the textarea. You need to subtract the size of the scrollbar from the rect or else use the textarea’s clientHeight and clientWidth properties.

    sizeHandler() {
       this.overlay.style.height = this.textarea.clientHeight + 'px';
       this.overlay.style.width = this.textarea.clientWidth + 'px';
    }
    

    (2) The html adds an extra line to the textarea that is not mirrored in the overlay. This adds an additional 1rem to the height. To fix this problem, remove the carriage return in the html:

    change this:

      ...content
      </textarea>
    </div>
    

    to this:

      ...content</textarea>
    </div>
    

    In the snippet, the textarea text is black and the overlay is in red to make any position mismatch more obvious.

    class TextareaOverlay {
        /**
         * 
         * @param {HTMLDivElement} container 
         */
        constructor(container) {
            /** @type {HTMLTextAreaElement} */
            this.textarea = container.querySelector("textarea");
            /** @type {HTMLDivElement} **/
            this.overlay = document.createElement("div");
            this.overlay.classList.add("overlay");
            container.appendChild(this.overlay);
    
            this.textarea.addEventListener("input", this.inputHandler.bind(this));
            this.textarea.addEventListener("scroll", this.scrollHandler.bind(this));
            this.inputHandler();
            this.sizeHandler();
            this.scrollHandler();
            this.resizeObserver = new ResizeObserver(this.sizeHandler.bind(this));
            this.resizeObserver.observe(this.textarea);
        }
        inputHandler() {
            const text = this.textarea.value;
            this.overlay.textContent = text;
        }
        scrollHandler() {
            this.overlay.scrollTop = this.textarea.scrollTop;
            this.overlay.scrollLeft = this.textarea.scrollLeft;
        }
        sizeHandler() {
            this.overlay.style.height = this.textarea.clientHeight + 'px';
            this.overlay.style.width = this.textarea.clientWidth + 'px';
        }
    };
    const textareaHelper = new TextareaOverlay(document.querySelector(".textarea-overlay"));
    div.textarea-overlay {
        position: relative;
    }
    div.textarea-overlay textarea {
        width: 100%;
        height: 100%;
        padding: 10px;
        font-family: 'Courier New', Courier, monospace;
        background-color: #f8f9fa;
        margin: 0;
        box-sizing: border-box;
        overflow: scroll;
        white-space: pre;
        font-size: 1em;
    }
    div.textarea-overlay div.overlay {
        color: crimson;
        margin: 0;
        position: absolute;
        font-size: 1em;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        padding: 10px;
        white-space: pre;
        font-family: 'Courier New', Courier, monospace;
        pointer-events: none;
        overflow: hidden;
        box-sizing: border-box;
        border: 1px solid transparent;
    }
    <div class="textarea-overlay" style="width: 400px; height: 500px">
    <textarea>
    div.textarea-overlay {
        position: relative;
    }
    div.textarea-overlay textarea {
        width: 100%;
        height: 100%;
        padding: 10px;
        font-family: 'Courier New', Courier, monospace;
        background-color: #f8f9fa;
        margin: 0;
        box-sizing: border-box;
        overflow: scroll;
        white-space: pre;
        font-size: 1em;
    }
    div.textarea-overlay div.overlay {
        margin: 0;
        position: absolute;
        font-size: 1em;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        padding: 10px;
        white-space: pre;
        font-family: 'Courier New', Courier, monospace;
        pointer-events: none;
        overflow: hidden;
        box-sizing: border-box;
        border: 1px solid transparent;
    }</textarea>
    </div>
    Login or Signup to reply.
  2. Fixing Alignment

    The textarea may have a different scroll area compared to a div, even when their contents are identical. This discrepancy varies across browsers and operating systems. Matching that would require dynamically adjusting the overlay’s dimensions in some way.

    Furthermore, when div.overlay has overflow: hidden, it appears to prevent the scrolling (even programmatic) if there is at least one child HTML element. Only plain text is allowed, and as a result, it is not possible to highlight the words.

    Introducing an extra div.content within the div.overlay and moving it with absolute positioning instead of scrolling would provide more flexibility and allow for any inner tags.

    Highlighting Matches

    In its most basic form, it wraps the matching text in tags (e.g., <em>) by using String.replaceAll().

    The value of a textarea must be HTML-escaped prior to highlighting, avoiding unintended code and maintaining the original alignment.

    Hiding Textarea

    Setting the color, background, and border to transparent will hide the original textarea. Optionally, outline: none; and appearance: none; conceal a few more features.

    Showing Input Cursor

    To ensure the insertion cursor remains visible even when the text is transparent, the caret-color CSS property can be applied.

    The highlights may cover the caret if they have a solid background. To prevent this, div.content should have a lower z-index than textarea.

    Result

    class TextareaOverlay {
        /**
         * 
         * @param {HTMLDivElement} container 
         * @param {HTMLInputElement} searchInput 
         */
        constructor(container, searchInput) {
            /** @type {HTMLTextAreaElement} */
            this.textarea = container.querySelector("textarea");
            /** @type {HTMLDivElement} **/
            this.overlay = document.createElement("div");
            this.overlay.classList.add("overlay");
            container.appendChild(this.overlay);
    
            this.search = searchInput;
            /** @type {HTMLDivElement} **/
            this.content = document.createElement("div");
            this.content.classList.add("content");
            this.overlay.appendChild(this.content);
    
            this.textarea.addEventListener("input", this.inputHandler.bind(this));
            this.textarea.addEventListener("scroll", this.scrollHandler.bind(this));
            this.search.addEventListener("input", this.inputHandler.bind(this));
            this.inputHandler();
            this.sizeHandler();
            this.scrollHandler();
            this.resizeObserver = new ResizeObserver(this.sizeHandler.bind(this));
            this.resizeObserver.observe(this.textarea);
        }
        inputHandler() {
            this.textarea.textContent = this.textarea.value;
            const escaped = this.textarea.innerHTML;
            const find = this.search.value.trim();
            this.content.innerHTML = !find.length ? escaped :
                escaped.replaceAll(find, "<em>" + find + "</em>");
        }
        scrollHandler() {
            this.content.style.top = (-this.textarea.scrollTop) + "px";
            this.content.style.left = (-this.textarea.scrollLeft) + "px";
        }
        sizeHandler() {
            const rect = this.textarea.getBoundingClientRect();
            this.overlay.style.width = rect.width + "px";
            this.overlay.style.height = rect.height + "px";
        }
    };
    const textareaHelper = new TextareaOverlay(
        document.querySelector(".textarea-overlay"),
        document.querySelector(".search"));
    div.textarea-overlay {
        position: relative;
        margin-top: 1em;
    }
    div.textarea-overlay textarea {
        width: 100%;
        height: 100%;
        padding: 10px;
        font-family: 'Courier New', Courier, monospace;
        background-color: #f8f9fa;
        margin: 0;
        box-sizing: border-box;
        overflow: scroll;
        white-space: pre;
        font-size: 1em;
        color: transparent;
        background: transparent;
        border: 1px solid transparent;
        caret-color: black;
    }
    div.textarea-overlay div.overlay {
        margin: 0;
        position: absolute;
        font-size: 1em;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        padding: 10px;
        white-space: pre;
        font-family: 'Courier New', Courier, monospace;
        pointer-events: none;
        overflow: hidden;
        box-sizing: border-box;
        border: 1px solid #aaa;
    }
    div.content {
        position: absolute;
        left: 0;
        top: 0;
        margin: 10px;
        z-index: -1;
    }
    div.content em {
        background: yellow;
    }
    <label>
        Find: <input class="search" value="div" placeholder="Search and highlight" title="Search and highlight">
    </label>
    
    <div class="textarea-overlay" style="width: 400px; height: 500px">
    <textarea>
    div.textarea-overlay {
        position: relative;
    }
    div.textarea-overlay textarea {
        width: 100%;
        height: 100%;
        padding: 10px;
        font-family: 'Courier New', Courier, monospace;
        background-color: #f8f9fa;
        margin: 0;
        box-sizing: border-box;
        overflow: scroll;
        white-space: pre;
        font-size: 1em;
    }
    div.textarea-overlay div.overlay {
        margin: 0;
        position: absolute;
        font-size: 1em;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        padding: 10px;
        white-space: pre;
        font-family: 'Courier New', Courier, monospace;
        pointer-events: none;
        overflow: hidden;
        box-sizing: border-box;
        border: 1px solid transparent;
    }
    </textarea>
    </div>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search