Warm tip: This article is reproduced from serverfault.com, please click

Programmatically change input value in Facebook's editable div input area

发布于 2020-11-27 16:43:39

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.

Questioner
m3h0w
Viewed
0
raandremsil 2020-12-06 20:22:33

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).