2

I'm trying to write a Chrome Extension that needs to be able to insert a character at the cursor location in an input field.

It's very easy when the input is an actual HTMLInputElement (insertAtCaretInput borrowed from another stack answer):

function insertAtCaretInput(text) {
  text = text || '';
  if (document.selection) {
    // IE
    this.focus();
    var sel = document.selection.createRange();
    sel.text = text;
  } else if (this.selectionStart || this.selectionStart === 0) {
    // Others
    var startPos = this.selectionStart;
    var endPos = this.selectionEnd;
    this.value = this.value.substring(0, startPos) + text + this.value.substring(endPos, this.value.length);
    this.selectionStart = startPos + text.length;
    this.selectionEnd = startPos + text.length;
  } else {
    this.value += text;
  }
}

HTMLInputElement.prototype.insertAtCaret = insertAtCaretInput;

onKeyDown(e){
  ...
  targetElement = e.target;
  target.insertAtCaret(charToInsert);
  ...
}

But the moment an input is actually represented differently in the HTML structure (e.g. Facebook having a <div> with <span> elements showing up and consolidating at weird times) I can't figure out how to do it reliably. The new character disappears or changes position or the cursor jumps to unpredictable places the moment I start interacting with the input.

Example HTML structure for Facebook's (Chrome desktop page, new post or message input fields) editable <div> containing string Test :

<div data-offset-key="87o4u-0-0" class="_1mf _1mj">
  <span>
    <span data-offset-key="87o4u-0-0">
      <span data-text="true">Test
      </span>
    </span>
  </span>
  <span data-offset-key="87o4u-1-0">
    <span data-text="true"> 
    </span>
  </span>
</div>

Here's my most successful attempt so far. I extend the span element like so (insertTextAtCursor also borrowed from another answer):

function insertTextAtCursor(text) {
  let selection = window.getSelection();
  let range = selection.getRangeAt(0);
  range.deleteContents();
  let node = document.createTextNode(text);
  range.insertNode(node);

  for (let position = 0; position != text.length; position++) {
    selection.modify('move', 'right', 'character');
  }
}

HTMLSpanElement.prototype.insertAtCaret = insertTextAtCursor;

And since the element triggering key press events is a <div> that then holds <span> elements which then hold the text nodes with the actual input, I find the deepest <span> element and perform insertAtCaret on that element:

function findDeepestChild(parent) {
  var result = { depth: 0, element: parent };

  [].slice.call(parent.childNodes).forEach(function (child) {
    var childResult = findDeepestChild(child);
    if (childResult.depth + 1 > result.depth) {
      result = {
        depth: 1 + childResult.depth,
        element: childResult.element,
        parent: childResult.element.parentNode,
      };
    }
  });

  return result;
}

onKeyDown(e){
  ...
  targetElement = findDeepestChild(e.target).parent; // deepest child is a text node
  target.insertAtCaret(charToInsert);
  ...
}

The code above can successfully insert the character but then strange things happen when Facebook's behind-the-scenes framework tries to process the new value. I tried all kinds of tricks with repositioning the cursors and inserting <span> elements similar to what seems to be happening when Facebook manipulates the dom on inserts but in the end, all of it fails one way or another. I imagine it's because the state of the input area is held somewhere and is not synchronized with my modifications.

Do you think it's possible to do this reliably and if so, how? Ideally, the answer wouldn't be specific to Facebook but would also work on other pages that use other elements instead of HTMLInputElement as input fields but I understand that it might not be possible.

Michał Gacka
  • 2,935
  • 2
  • 29
  • 45
  • Can you add an image what input field you are trying to change? – Anton Nov 30 '20 at 11:16
  • @Anton it's the Create Post input and the little Messaging/Chat window input that appears on the bottom that. They seem to be working similarly so you can assume that making it work with Create Post will be sufficient for simplicity – Michał Gacka Nov 30 '20 at 11:45

3 Answers3

3

I had a similar problem with Whatsapp web
try to dispatch an input event

target.dispatchEvent(new InputEvent('input', {bubbles: true}));
ATP
  • 2,939
  • 4
  • 13
  • 34
  • I tried it and it seems to be neither picked up by Whatsapp nor Facebook. Of course with a data field in the dictionary in the constructor. – Michał Gacka Dec 01 '20 at 14:14
  • Are you sure that targetElement is the ContentEditable div? – ATP Dec 01 '20 at 16:25
  • 1
    try this fuction: function findFirstContentEditable(el){ if(el.children.length==0) return el.children.isContentEditable?el.children:null; for (var i = 0; i < el.length; i++) { var r= findFirstContentEditable(el.children[i]); if(r!=null) return r; } } – ATP Dec 01 '20 at 16:55
  • Using the event indeed turned out to be helpful but it was only the tip of the ice berg, because of the strange ways in which Facebook merges the `span` elements it creates within the `div` that constitutes the input element. – Michał Gacka Dec 22 '20 at 12:56
  • yes, but it was my only problem because I just used `docunent.activeElement` – ATP Dec 22 '20 at 15:25
2

I've made a Firefox extension that is able to paste a remembered note from context menu into input fields. However Facebook Messenger fields are heavly scripted divs and spans - not input fields. I've struggled to make them work and dispatching an event as suggested by @user9977151 helped me!

However it needs to be dispatched from a specific element and also you need to check if your Facebook Messenger input field is empty or not.

Empty field will look like that:

<div class="" data-block="true" data-editor="e1m9r" data-offset-key="6hbkl-0-0">
    <div data-offset-key="6hbkl-0-0" class="_1mf _1mj">
        <span data-offset-key="6hbkl-0-0">
            <br data-text="true">
        </span>
    </div>
</div>

And not empty like that

<div class="" data-block="true" data-editor="e1m9r" data-offset-key="6hbkl-0-0">
    <div data-offset-key="6hbkl-0-0" class="_1mf _1mj">
        <span data-offset-key="6hbkl-0-0">
            <span data-text="true">
                Some input
            </span>
        </span>
    </div>
</div>

The event needs to be dispatched from

<span data-offset-key="6hbkl-0-0">

It's simple when you add something to not empty field - you just change the innerText and dispatch the event.

It's more tricky for an empty field. Normally when the user writes something <br data-text="true"> changes into <span data-text="true"> with the user's input. I've tried doing it programically (adding a span with innerText, removing the br) but it broke the Messenger input. What worked for me was to add a span, dispatch the event and then remove it! After that Facebook removed br like it normally does and added span with my input.

Facebook seems to somehow store user keypresses in it's memory and then input them itself.

My code was

if(document.body.parentElement.id == "facebook"){
    var dc = getDeepestChild(actEl);
    var elementToDispatchEventFrom = dc.parentElement;
    let newEl;
    if(dc.nodeName.toLowerCase() == "br"){
        // attempt to paste into empty messenger field
        // by creating new element and setting it's value
        newEl = document.createElement("span");
        newEl.setAttribute("data-text", "true");
        dc.parentElement.appendChild(newEl);
        newEl.innerText = message.content;
    }else{
        // attempt to paste into not empty messenger field
        // by changing existing content
        let sel = document.getSelection();
        selStart = sel.anchorOffset;
        selStartCopy = selStart;
        selEnd = sel.focusOffset;

        intendedValue = dc.textContent.slice(0,selStart) + message.content + dc.textContent.slice(selEnd);
        dc.textContent = intendedValue;
        elementToDispatchEventFrom = elementToDispatchEventFrom.parentElement;
    }
    // simulate user's input
    elementToDispatchEventFrom.dispatchEvent(new InputEvent('input', {bubbles: true}));
    // remove new element if it exists
    // otherwise there will be two of them after
    // Facebook adds it itself!
    if (newEl) newEl.remove();
}else ...

where

function getDeepestChild(element){
  if(element.lastChild){
    return getDeepestChild(element.lastChild)
  }else{
    return element;
  }
}

and message.content was a string that I wanted to be pasted into Messenger field.

This solution can change the content of Messenger field but will move the cursor to the beginning of the field - and I'm not sure if is possible to keep the cursor's position unchanged (as there's no selectionStart and selectionEnd that could be changed).

  • Thanks for the input! I arrived at a similar result and even managed to move the cursors to the right spot but then it turned out that when something works well in one type of field it breaks in the other. And the edge cases, like trying to input the emoji without any other text being there yet would just create way too much code for handling such a simple situation. Not to mention what would happen when FB decides to update their code. The strange world of web development where adding a few characters to an input field becomes a struggle. – Michał Gacka Dec 22 '20 at 13:00
  • @m3h0w Strange word indeed! But I may have managed to get cursor's position right. After the input the cursor is at position 0. Then I do `sel = document.getSelection();`, next I move cursor by one character (which doesn't mean only one position for strange unicode characters) by `sel.modify("move", "right", "character");` and then do `sel = document.getSelection();` again to compare `sel.focusOffset < n` where `n` is desired position. While it's true I continue with `sel.modify` until the offset is right. I think it works right for Facebook fields. :) (guard from infinite loop may be needed) – raandremsil Jan 10 '21 at 19:20
  • Man awesome! I spent so long trying to update a Facebook "input". You saved my life! Thank you so much – Valentin Vignal Sep 04 '21 at 08:43
1

The answer of @raandremsil and @ATP mostly worked for me. However, I had a case when it was not working.

My extension displays a list of texts that will be inserted in the input when the user clicks on an item. It was not working properly until I focused on the field before modifying the content and dispatching the event.

const myNewText = 'whatever text I get a click on my list';
elementToDispatchEventFrom.focus();  // <- I had to add this line, focus the field before editing (`elementToDispatchEventFrom` is from raandremsil's answer)
deepestChild.contextText = myNewText; // Edit the text
elementToDispatchEventFrom.dispatchEvent(new InputEvent('input', {bubbles: true})); // Dispatch event to simulate user input

UPDATE 27/02/2022:

This stopped working for some reason. Here is was I am doing now to replace a text in the input, assuming it is just before the cursor position.

const selection = window.getSelection()!;
const cursorPosition = selection.focusOffset;

const focusNode = selection.focusNode!;
const doc = focusNode.ownerDocument!;

const range = new Range();
range.setStart(focusNode, cursorPosition - textToReplace.length);
range.setEnd(focusNode, cursorPosition);

selection.removeAllRanges();
selection.addRange(range);

doc.execCommand('insertText', false, myNewText); // Careful, for some reason, this does not work if `myNewText` ends with the space.
Valentin Vignal
  • 6,151
  • 2
  • 33
  • 73