WK Overlay

Overlays component information on reviews when show info is pressed.

// ==UserScript==
// @name         WK Overlay
// @namespace    wkoverlay
// @version      0.3.4
// @description  Overlays component information on reviews when show info is pressed.
// @author       Ethan
// @match        https://www.wanikani.com/review/session*
// @grant        none
// ==/UserScript==

// TODO if kanji or radical follows vocab, overlay is not removed
var addStyleTag = function(){
        /* Non-Javascript determined (to be put in style tag)*/ 
        var styleText =
        "@media (max-width: 767px) .wkOverlay {\r\n" +
            "font-size: 3.0625em;\r\n" +
            "font-weight: normal;\r\n" +
            "line-height: 2.4em;\r\n" +
        "}\r\n" +
        ".wkOverlay {\r\n" +
            "display: block;\r\n" +
            "position: absolute;\r\n" +
        "}\r\n" +
        ".infoSpan {\r\n" +
            "position: absolute;\r\n" +
            "background-color: black;\r\n" +
            "color: #fff;\r\n" +
            "text-align: center;\r\n" +
            "padding: 5px 10px;\r\n" +
            "border-radius: 6px;\r\n" +
            "display: inline-flex;\r\n" +
            "font-size: 10pt;\r\n" +
            "margin-right:0 2%;\r\n" +
        "}\r\n" +
        ".infoRadSpan {\r\n" +
            "position: absolute;\r\n" +
            "background-color: black;\r\n" +
            "color: #fff;\r\n" +
            "text-align: center;\r\n" +
            "padding: 5px 10px;\r\n" +
            "border-radius: 6px;\r\n" +
            "display: inline-flex;\r\n" +
            "font-size: 10pt;\r\n" +
        "}\r\n" +
        ".infoTop::after{\r\n" +
            "content: '';\r\n" +
            "position: absolute;\r\n" +
            "top: 100%;\r\n" +
            "left:20px;\r\n" +
            "margin-left: -10px;\r\n" +
            "border-width: 10px;\r\n" +
            "border-style: solid;\r\n" +
            "border-color: black transparent transparent transparent\r\n" +
        "}\r\n" +
        ".infoTop{\r\n" +
            "bottom: 100%;\r\n" +
        "}\r\n" +
        ".infoBottom{\r\n" +
            "top: 100%;\r\n" +
        "}\r\n" +
        ".infoBottom::after{\r\n" +
            "content: '';\r\n" +
            "position: absolute;\r\n" +
            "bottom: 100%;\r\n" +
            "left:20px;\r\n" +
            "margin-left: -10px;\r\n" +
            "border-width: 10px;\r\n" +
            "border-style: solid;\r\n" +
            "border-color: transparent transparent black transparent\r\n" +
        "} \r\n"+
        "span.wkOverlayRadBox\r\n{" +
            "background-color: #0af;" +
            "display: inline-block;" +
            "margin-right: 0.3em;" +
            "width: 1.8em;" +
            "height: 1.8em;" +
            "color: #fff;" +
            "line-height: 1.7em;" +
            "text-align: center;" +
            "text-shadow: 0 1px 0 rgba(0,0,0,0.3);" +
            "-webkit-box-sizing: border-box;" +
            "-moz-box-sizing: border-box;" +
            "box-sizing: border-box;" +
            "-webkit-border-radius: 3px;" +
            "-moz-border-radius: 3px;" +
            "border-radius: 3px;" +
            "-webkit-box-shadow: 0 -3px 0 rgba(0,0,0,0.2) inset, 0 0 10px rgba(255,255,255,0.5);" +
            "-moz-box-shadow: 0 -3px 0 rgba(0,0,0,0.2) inset,0 0 10px rgba(255,255,255,0.5);" +
            "box-shadow: 0 -3px 0 rgba(0,0,0,0.2) inset, 0 0 10px rgba(255,255,255,0.5);" +
        "}\r\n" +
        "";
        var styleElem = document.createElement('style');
        styleElem.appendChild(document.createTextNode(styleText));
        document.head.appendChild(styleElem);
};

(function() {
    'use strict';

    // Salt this event so future scripts don't throw multiple events and confuse the script.
    // This should ensure these events are unique to this script and minimise the chances of catching events in error.
    // TODO: handle possibility of multiple events being thrown by other scripts based on context (eg. multiple 'show' events without a 'hide' => 1 'show') for greater integration between scripts.
    var hideEvent = "hide"+Math.trunc(Math.random()*1000000);
    var showEvent = "show"+Math.trunc(Math.random()*1000000);
//---------------------------
    var oldHide = $.fn.hide;
    $.fn.hide = function(){this.trigger(new jQuery.Event(hideEvent)); return oldHide.apply(this, arguments);};
    var oldShow = $.fn.show;
    $.fn.show = function(){this.trigger(new jQuery.Event(showEvent)); return oldShow.apply(this, arguments);};
//--------------------------

    addStyleTag();

    var parentElement = $("#question");
    var overlay = document.createElement('div');
    overlay.style.display = 'none';
    overlay.className = "wkOverlay";
    overlay.setAttribute('lang', "ja");

    var respComps = {};
    var masterRads = {};
    var prompt = {};
    parentElement[0].appendChild(overlay);
    var newItem = false;

    // just for comparing prompts
    var shallowCompare = function(obj1, obj2){
        return ((obj1.voc && obj2.voc) || (obj1.kan && obj2.kan) || (obj1.rad && obj2.rad)) && ((obj1.voc === obj2.voc) || (obj1.kan === obj2.kan) || (obj1.rad === obj2.rad));
    };
    $.jStorage.listenKeyChange('currentItem', function(){
        while (overlay.firstChild){
            overlay.removeChild(overlay.firstChild);
        }
        // Runs after span is changed, but is this guaranteed?
        var oldPrompt = prompt;
        prompt = $.jStorage.get('currentItem');
        newItem = !shallowCompare(prompt, oldPrompt);

        var itemString = prompt.voc||prompt.kan; // The kanji, vocab (or soon, radical)
        if (itemString){
            for (var ch in itemString){ // Kanji and radical, this will only be one (radical images may need some other handling)
                var chSpan = document.createElement('span');

                chSpan.innerText = itemString[ch];

                chSpan.style.fontSize = document.defaultView.getComputedStyle($("#character")[0], "").fontSize;
            }
        }
    });

    var showRadicalTags = function(){
        console.warn("showtags");
        overlay.style.display = 'block';
        //Since we are currently adding the tips every time we catch a show event, we must delete them all first, or multiple hide/show events will build up more tips for every 'show'
        // Clear array of nodelists
        while (overlay.firstChild){
            overlay.removeChild(overlay.firstChild);
        }
        // Get the related kanji component info for vocab from the 'show answer' page
        // Create an object (respComps) indexed by Kanji characters. eg {"出":" Exit","提":" Present, Submit"}
        $("#related-items .kanji a").each(function(i, comp){

            //Needs love to be more robust when Wanikani makes cosmetic changes to their page
            respComps[comp.childNodes[1].textContent] = comp.childNodes[2].textContent;
        });

        if (prompt.voc){
            // Create flip flop variable so tips with multiple words don't clutter the top or bottom
            var flipTopBottom = false; //true: top, false: bottom
            // Values will be the same for all char, but need to be calculated
            // Position absolute needs negative margins to react to number of characters
            var marginLeftPercentage = (-100/prompt.voc.length) + "%";
            var marginRightPercentage = "2%";
            for (var ch in prompt.voc){
                flipTopBottom = !flipTopBottom;
                var chSpan = document.createElement('span');
                chSpan.className = "wkOverlayChar";

                var comp = document.createElement('span');
                comp.innerText = prompt.voc[ch];
                comp.style.opacity = 0;
                chSpan.appendChild(comp);

                console.info(respComps, prompt.voc[ch], respComps[prompt.voc[ch]]);
                chSpan.style.fontSize = document.defaultView.getComputedStyle($("#character")[0], "").fontSize; // and lineHeight

                // Javascript determined style values.
                overlay.style.height = $("#character span").height() + 'px';
                overlay.style.top = $("#character span").position().top + 'px';
                overlay.style.left = $("#character span").position().left + 'px';
                overlay.style.height = $("#character span").height() + 'px';

                overlay.appendChild(chSpan);
//debugger;
                if (respComps[prompt.voc[ch]]){ // any characters not present in the components section will fail here. There should be no Kanji in the prompt that is not here, so no Kanji should fail.
                    var spInf = document.createElement('span');
                    spInf.innerText = respComps[prompt.voc[ch]];

                    chSpan.appendChild(spInf);
                    spInf.style.marginLeft = marginLeftPercentage;
                    //                    spInf.style.marginRight = marginRightPercentage;
                    if(flipTopBottom){
                        spInf.className = "infoSpan infoTop";
                    }
                    else{
                        spInf.className = "infoSpan infoBottom";
                    }
                }
            }
        }
        if (prompt.kan){
            var respRads = {};
            var slug;
            $("#related-items .radical a").each(function(i, comp){
                //debugger;

                // Needs improvement for when Wanikani changes its page structure
                if (comp.childNodes[1].textContent){
                    slug = comp.childNodes[1].textContent;
                    respRads[slug] = {name: comp.childNodes[2].textContent, kanji: {}};
                }
                else{
                    slug = comp.childNodes[1].firstChild.className;// i class
                    respRads[slug] = {box: comp.childNodes[1].firstChild, name: comp.childNodes[2].textContent, kanji: {}};
                }

                //get position from browser storage if there
                if ($.jStorage.get("overlayMasterRads")){
                    masterRads =  $.jStorage.get("overlayMasterRads");
                }

                if (!masterRads[slug]){
                    masterRads[slug] = respRads[slug];
                }

                if (masterRads[slug].kanji[prompt.kan]){
                    respRads[slug].kanji[prompt.kan] = masterRads[slug].kanji[prompt.kan];
                }
                else{
                    respRads[slug].kanji[prompt.kan] = {x: 0, y: 0};
                }
            });
            console.log("masterRads:", masterRads);
            var kan = document.createElement('span');
            kan.className = "wkOverlayChar";

            kan.innerText = prompt.kan;
            kan.style.opacity = 0.5; //debug kanji
            //kan.appendChild(comp);

            console.info("component object, kanji, compObj[kanji]", respComps, prompt.kan, respComps[prompt.kan]);// will probably only work on kanji that has itself as a radical (eg. 一)
            kan.style.fontSize = document.defaultView.getComputedStyle($("#character")[0], "").fontSize; // and lineHeight

            var overlayLeftOffset = $("#character span").position().left;
            var overlayTopOffset = $("#character span").position().top;
            // Javascript determined style values.
            overlay.style.height = $("#character span").height() + 'px';
            overlay.style.top = $("#character span").position().top + 'px';
            overlay.style.left = overlayLeftOffset + 'px';
            //overlay.style.right = (overlayLeftOffset+$(overlay).width()) + 'px';
            //overlay.style.right = (-overlayRightOffset) + 'px';
            //alert(overlay.style.right);
            overlay.style.height = $("#character span").height() + 'px';

            //overlay.appendChild(kan);

            //respComps is radicals and their names as key->value pairs
            // we need to give the radicals some more info (position, svg path around relevant part of kanji) than the kanji has
            // add this info for each radical: object as scraped: {"疒":" Sick","正":" Correct"} => fn => {"疒":{name:" Sick", "症":{x:num, y:num, svg: svgElem },"正":{name:" Correct", "症":{x:num, y:num, svg: svgElem }}
            // the kanji info bit: "症":{x:num, y:num, svg: svgElem }, needs to be creatable, editable, and retrievable.

            var dragMouseDownHandler = function(evt){
                console.info("mousedown event was heard", evt);
                evt.preventDefault();
                console.log('this == ', this);
                this._mouseDownOrigin = {x: evt.clientX, y: evt.clientY};
                //var elStyle = document.defaultView.getComputedStyle(evt.target, "");
                var elStyle = document.defaultView.getComputedStyle(this, "");
                var l = elStyle.left; var t = elStyle.top;
                //var l = evt.target.style.left; var t = evt.target.style.top;
                this._originalPosition = {x: parseFloat(l.substr(0, l.length-1)), y: parseFloat(t.substr(0, t.length-1))};
                this._mouseMoveHandler = dragMouseMoveHandler.bind(this);
                this._mouseUpHandler = dragMouseUpHandler.bind(this);
                document.addEventListener('mousemove', this._mouseMoveHandler);
                //document.addEventListener('mousemove', dragMouseMoveHandler);
                document.addEventListener('mouseup', this._mouseUpHandler);
                document.addEventListener('mouseout', this._mouseUpHandler);
                //                this.addEventListener('mouseup', dragMouseUpHandler);
                //                this.addEventListener('mouseout', dragMouseUpHandler);
            };
            var dragMouseMoveHandler = function(evt){
                //nsole.log("mousemove", evt, this);
                if (this._mouseDownOrigin){

                    var dx = evt.clientX - this._mouseDownOrigin.x;
                    //console.log("dx", dx);
                    var dy = evt.clientY - this._mouseDownOrigin.y;
                    var l = this.style.left||"0px"; var t = this.style.top||"0px";
                    if (l[l.length-1] === "%" || t[t.length-1] === "%"){
                            console.log("positioned with % ??");
                    }
                    else{
                        var newLeft = Math.max(-overlayLeftOffset, this._originalPosition.x + dx) + "px";
                        console.log("-overlayLeftOffset", -overlayLeftOffset, "this._originalPosition.x", this._originalPosition, "dx", dx);
                        
                        var newTop = Math.max(-overlayTopOffset, this._originalPosition.y + dy) + "px";
                        //want the highest negative number to keep it in the document
                        console.log("moving div:" + newLeft + " " + newTop);
                        this.style.left = newLeft;
                        this.style.right = -'100px';
                        this.style.top = newTop;
                    }
                }
            };
            var dragMouseUpHandler = function(evt){
                if (evt.type === 'mouseup' || evt.type === 'mouseout' && (evt.toElement === null)){ // mouseout leaves entire document
                    // Check if this radical has a payload for the kanji, give it one if not.
                    masterRads[this.slug].kanji[prompt.kan] = {x: this.style.left, y:this.style.top};
                    //console.log("find x and y?", evt);
                    $.jStorage.set("overlayMasterRads", masterRads);

                    console.log("removing listeners", evt.type);
                    document.removeEventListener('mousemove', this._mouseMoveHandler);
                    document.removeEventListener('mouseup', this._mouseUpHandler);
                    document.removeEventListener('mouseout', this._mouseUpHandler);
                }
            };

            // Order is not important for radicals since they are just like, all over the kanji, so we will just iterate.
            for (var rad in respRads){
                var spRadName = document.createElement('span');
                var radBox = document.createElement('span');
                radBox.setAttribute("lang", "ja");
                radBox.className = "wkOverlayRadBox";
                if (!respRads[rad].box){
                    radBox.appendChild(document.createTextNode(rad));
                }
                else{
                    radBox.appendChild(respRads[rad].box.cloneNode());
                }
                spRadName.slug = rad;
                spRadName.appendChild(radBox);
                spRadName.appendChild(document.createTextNode(respRads[rad].name));

                overlay.appendChild(spRadName);
                spRadName.style.marginLeft = (-100)*Math.random() + "%"; //more likely to see them if they are changed up a bit while we code
                spRadName.style.left = respRads[rad].kanji[prompt.kan].x;
                spRadName.style.top = respRads[rad].kanji[prompt.kan].y;
                spRadName.style.cursor = "move";
                spRadName.className = "infoRadSpan";

                //-- todo keep track of these in objects, enable/disable handlers in an edit/setup mode
                //masterRads.currentSlug = slug;
                spRadName.addEventListener('mousedown', dragMouseDownHandler);
            }
        }
    };

    var observer = new MutationObserver(showRadicalTags);
    //observer = new MutationObserver(function(mutation){console.log(mutation);});

    var testObserve = new MutationObserver(function(mutations){
                console.info("%cObserver", "background-color:grey");
                console.info(typeof mutations);
                console.groupCollapsed("Mutation List");
                mutations.forEach(function(mutation){console.info(mutation.type);});
                console.groupEnd();
    });

    // Show overlay when info is being shown.
    $("#item-info").on(showEvent, function(evt){
        console.info("showevt", evt);
        if (newItem){
            newItem = false;
            //watch related items for change
            observer.observe($("#item-info")[0], {childList: true, subtree:true});
        }
        else{
            showRadicalTags();
        }
    });

    $("#item-info").on(hideEvent, function(evt){
        if (evt.target.id === "additional-content-load"){
            console.info("hide evt", evt);
            //debugger;
            overlay.style.display = 'none';
        }
    });
})();