Dealing with contenteditable and pdf.js

I write software for myself. Not only does this allow me to improve my life, but it has the added benefit of turning me into my own QA department.

Recently I was reading a pdf with a small quiz. I decided to use my Temp Notes extension, as quick notes are what it was built for. This revealed that on pdf's, I could type, but not navigate with the arrow keys or delete. This was obviously not ideal. I hoped this would be easy fix - below are the chronicles of my insanity.

Assumption 1: pdf.js is capturing keystrokes!

Kind of. It does make use of a key-handler:

PDFViewer.prototype = {
  handleKeyEvent_: function(e) {
    var pageDownHandler = function() {
      // Some stuff
      e.preventDefault();
    }.bind(this);

/* Cut out extraneous stuff */

    switch (e.keyCode) {
      //Some keys we don't care about
      case 32:  // Space key.
        if (e.shiftKey)
          pageUpHandler();
        else
          pageDownHandler();
        return;
      case 34:  // Page down key.
        pageDownHandler();
        return;
      case 38:  // Up arrow key.
        if (fromScriptingAPI) {
          position.y -= Viewport.SCROLL_INCREMENT;
          this.viewport.position = position;
        }
        return;
      case 65:  // a key.
        if (e.ctrlKey || e.metaKey) {
          this.plugin_.postMessage({
            type: 'selectAll'
          });
          // Since we do selection ourselves.
          e.preventDefault();
        }
        return;
    }

This reveals a couple of things. For one, it's encapsulated in a prototype, so if this is the issue, we are screwed. But it's clearly not the issue, because we can type just fine, and minus page up/down and ctrl+a, none of the events are being swallowed.

Assumption 2: My contenteditable isn't getting keypresses because Chrome is redirecting them to the pdf?

Seems reasonable. Also incorrect:

$( '#note-' + note_id ).on( 'keydown', function( e ) {
    console.log( e.which ); //this fires successfully
});
Assumption 3: Maybe the DOM is fucked?

This is the DOM structure of a basic page rendering a pdf:

<html>
    <body style="background-color: rgb(38, 38, 38); height: 100%; width: 100%; overflow: hidden; margin: 0px;">
        <embed width="100%" height="100%" name="plugin" id="plugin" src="pdf-sample.pdf" type="application/pdf" internalinstanceid="9">
    </body>
</html>

Nothing weird there I guess... what about the plugin?

<html dir="ltr" lang="en"><head>
  <!-- Bunch of css -->
<title>pdf-sample.pdf</title></head>
<body>

<!-- Bunch of scripts -->

<embed id="plugin" type="application/x-google-chrome-pdf" full-frame=""></body></html>

Wtf? We are rendering an <html> tag inside a <body> tag? I mean, technically it's legal:

<html>
<body id="body1">
<html>
<body id="body2">
hi
</body>
</html>
</body>
</html>

But it has a unique trait:

console.log( $('body') );
>> jQuery.fn.init[1]

When we scour the DOM, we only find the first body. There is also another odd thing:

console.log( document.getElementById( "body2" ) )
>> null

Uhh, well that's probably not good. This means if we have items rendered outside the scoped body, they are effectively invisible to the DOM.

Assumption 4: Let's just make something up

Here's where after glossing over all the code I've thrown out, you shamelessly believe my conclusion that has no merits.

On a normal keystroke, the contenteditable is treated as a normal input box, with the text being rendered first and then the key handler for pdf.js being invoked. No issues.

On a special keystroke (one that produces no unicode character), there's a different flow. Since contenteditables aren't rendered as normal input boxes, they require some special logic that is tacked on in an eventhandler (to deal with things like RTL-language support, touchscreen keyboards, etc.) So:

Keypress -> pdj.js -> returns to scoped embedded body -> embedded body searches for a contenteditable -> can't find it because it is in the parent body. 

Or maybe embeds are sandboxed. I don't fucking know.

I'm definitely a scientist. Whatever, how the fuck do we fix this? If your answer was an ugly-as-fuck hack, good job:

/*!
* Checks for the existance of an embedded pdf that is being rendered via Chrome's 
* native pdf reader or Adobe.
*/
function pdf_embedded( ) {
    return $('embed[type="application/pdf"]').length > 0;
}

/*!
* For a contenteditable div, set the cursor to the given index position on the given line number.
*
* div: A document selector to the contenteditable div. Not a jQuery selector.
* line: The line to place the cursor on. 0-based.
* index: The index on the line to place the cursor at. 0-based.
*/
function set_selection( div, line, index ) {
    var range = document.createRange( );

    if( div.childNodes.length == 0 )
        return;

    //If we are at the top element of the contenteditable, it needs to be treated as text and not a div.
    if( div.childNodes[ line ].childNodes.length == 0 ) {
        range.setStart( div.childNodes[ line ], index );
    }
    else {
        range.setStart( div.childNodes[ line ].childNodes[ 0 ], index );
    }
    range.collapse( true );

    var selection = window.getSelection( );
    selection.removeAllRanges( );
    selection.addRange( range );
}

// Make sure to store the current line some way.
// Here I use a hidden input.
if( pdf_embedded( ) ) {
    $( '#note-' + note_id ).on( 'keydown', function( e ) {
        var $current_line = $( this ).parent( ).find( '#current_line' );
        var selection = window.getSelection( );

        switch( e.which ) {
            case 8:             //backspace
                var current_pos = selection.anchorOffset;
                var $current_text = $( this.childNodes[ $current_line.val( ) ] );

                //If there is nothing, prevent the deletion of the contenteditable object.
                if( this.childNodes.length == 0 || ( current_pos == 0 && $current_line.val( ) == 0 ) ) 
                    break;

                //The first element of contenteditable's are treated as text nodes,
                //with future elements being wrapped divs.
                if( this.childNodes[ $current_line.val( ) ].childNodes.length == 0 ) {
                    $current_text.parent( ).text( $current_text.parent( ).text().substring( 0, current_pos - 1 ) + $current_text.parent( ).text( ).substring( current_pos ) );
                }
                else {
                    $current_text.text( $current_text.text().substring( 0, current_pos - 1 ) + $current_text.text( ).substring( current_pos ) );
                }

                if( $current_text.text().length == 0 ) {
                    $current_text.remove( );
                    if( $current_line.val( ) > 0 ) {
                        $current_line.val( parseInt( $current_line.val( ) ) - 1 );
                    }
                }

                //If we still have text, move one left, otherwise, jump up to the previous line.
                if( current_pos > 0 && !( current_pos == 1 && $( this.childNodes[ $current_line.val( ) ] ).text( ).length == 0) ) {
                    set_selection( this, $current_line.val( ), current_pos - 1 < 0 ? 0 : current_pos - 1 );
                }
                else {
                    set_selection( this, $current_line.val( ), $( this.childNodes[ $current_line.val( ) ] ).text( ).length  );
                }

                break;
            case 13:            //enter
                $current_line.val( parseInt( $current_line.val( ) ) + 1 );
                break;
            case 37:            //left
                if( selection.anchorOffset > 0 ) {
                    set_selection( this, $current_line.val( ), selection.anchorOffset - 1 );
                }
                break;
            case 38:            //up
                if( $current_line.val( ) > 0 ) {
                    $current_line.val( parseInt( $current_line.val( ) ) - 1 );
                    set_selection( this, $current_line.val( ), 0 );
                }
                break;
            case 39:            //right
                if( selection.anchorNode.wholeText != undefined && selection.anchorOffset < selection.anchorNode.wholeText.length ) {
                    set_selection( this, $current_line.val( ), selection.anchorOffset + 1 );
                }
                break;
            case 40:            //down
                if( $current_line.val( ) < this.childNodes.length - 1 ) {
                    $current_line.val( parseInt( $current_line.val( ) ) + 1 );
                    set_selection( this, $current_line.val( ), 0 );
                }
                break;
        }
    }); 
}

At least drinking myself to sleep is easy.