Wanikani hanzi-writer addition

Replaces kanji in wanikani with hanzi writer. Licenses for kanji data are found at https://github.com/chanind/hanzi-writer-data-jp/

// ==UserScript==
// @name        Wanikani hanzi-writer addition
// @namespace   https://declanfodor.com
// @description Replaces kanji in wanikani with hanzi writer. Licenses for kanji data are found at https://github.com/chanind/hanzi-writer-data-jp/
// @match       https://www.wanikani.com/*
// @version     0.0.4
// @author      Declan Fodor
// @resource    kanjiJSON https://raw.githubusercontent.com/chanind/hanzi-writer-data-jp/master/data/all.json
// @require     https://cdn.jsdelivr.net/npm/hanzi-writer@3.5/dist/hanzi-writer.js
// @require     https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
// @license     MIT
// @grant       GM_addStyle
// @grant       GM_getResourceText
// @grant       GM_getValue
// @grant       GM_setvalue
// ==/UserScript==

(function () {
'use strict';

const WK_PAGE = Object.freeze({
  REVIEW: Symbol("review_page"),
  LESSON: Symbol("lesson_page"),
  DASHBOARD: Symbol("dashboard_page"),
  LOADING: Symbol("other_page") // Or a page we haven't implemented behavior for yet
});
class PageStatus {
  constructor(previousStatus) {
    this.page = this.whichPage(unsafeWindow.location.href);
    this.switched = previousStatus ? previousStatus.page !== this.page : true;
  }
  whichPage(url) {
    switch (url) {
      case "https://www.wanikani.com/":
        return WK_PAGE.DASHBOARD;
      case "https://www.wanikani.com/subjects/review":
        return WK_PAGE.REVIEW;
      default:
        return WK_PAGE.LOADING;
    }
  }
}

class PageObserver {
  constructor(wk_page, onPage, offPage) {
    this.status = new PageStatus(null);
    this.observer = new MutationObserver(() => {
      if (this.status.switched && this.status.page === wk_page) {
        onPage();
      } else if (this.status.switched) {
        offPage();
      }
      this.status = new PageStatus(this.status);
    });
    this.observer.observe(document, {
      childList: true,
      subtree: true
    });
  }
}

let kanji_json = JSON.parse(GM_getResourceText("kanjiJSON"));
class ReviewPage {
  constructor() {
    this.kanji_elem = null;
    this.kanji = null;
    this.writer = null;
    this.container_div = null;
  }
  /**
   * Called whenever the kanji has switched. It creates the hanzi writer instance
   */
  drawHanziWriter() {
    if (!this.writer) {
      let character_header = document.querySelector(".quiz .character-header");
      this.container_div = document.createElement("div");
      this.container_div.id = "wkhwa-container-div";
      this.writer = HanziWriter.create(this.container_div, this.kanji, {
        showOutline: wkof.settings.wkhwa.showOutline,
        showCharacter: wkof.settings.wkhwa.showCharacter,
        width: wkof.settings.wkhwa.width,
        height: wkof.settings.wkhwa.height,
        padding: wkof.settings.wkhwa.padding,
        strokeAnimationSpeed: wkof.settings.wkhwa.strokeAnimationSpeed,
        strokeHighlightSpeed: wkof.settings.wkhwa.strokeHighlightSpeed,
        strokeFadeDuration: wkof.settings.wkhwa.strokeFadeDuration,
        delayBetweenStrokes: wkof.settings.wkhwa.delayBetweenStrokes,
        delayBetweenLoops: wkof.settings.wkhwa.delayBetweenLoops,
        strokeColor: wkof.settings.wkhwa.strokeColor,
        highlightColor: wkof.settings.wkhwa.highlightColor,
        outlineColor: wkof.settings.wkhwa.outlineColor,
        drawingColor: wkof.settings.wkhwa.drawingColor,
        drawingWidth: wkof.settings.wkhwa.drawingWidth,
        showHintAfterMisses: wkof.settings.wkhwa.showHintAfterMisses,
        quizStartStrokeNum: wkof.settings.wkhwa.quizStartStrokeNum,
        highlightOnComplete: wkof.settings.wkhwa.highlightOnComplete,
        charDataLoader: (char, on_load) => {
          on_load(kanji_json[char]);
        }
      });
      character_header.append(this.container_div);
    } else {
      this.writer.setCharacter(this.kanji);
    }
    if (wkof.settings.wkhwa.quiz) {
      this.writer.quiz();
    } else if (wkof.settings.wkhwa.animate) {
      if (wkof.settings.wkhwa.loop_animation) {
        this.writer.loopCharacterAnimation();
      } else {
        this.writer.animateCharacter();
      }
    }
  }
  onReviewPage() {
    this.observer = new MutationObserver(() => {
      if (this.refreshKanjiState()) {
        this.drawHanziWriter();
      }
    });
    this.kanji_elem = document.querySelector(".quiz .character-header .character-header__characters");
    if (this.refreshKanjiState()) {
      this.drawHanziWriter();
    }
    this.observer.observe(this.kanji_elem, {
      childList: true,
      subtree: true
    });
  }
  showHanziWriter() {
    this.kanji_elem.hidden = true;
    if (this.container_div) {
      this.container_div.hidden = false;
    }
  }
  hideHanziWriter() {
    this.kanji_elem.hidden = false;
    if (this.container_div) {
      this.container_div.hidden = true;
    }
  }
  /**
   * Returns true if the kanji shown has switched. Returns false otherwise
   * This function also manages hiding and showing kanji 
   * in the event that the characters shown are either a radical or vocabulary
   */
  refreshKanjiState() {
    // CHANGEME shouldn't this return an enum and have the logic outside this function?
    if (document.querySelector(".quiz-input__question-category").innerText.toLowerCase() === "kanji" && kanji_json[this.kanji_elem.innerText]) {
      if (this.kanji_elem.innerText !== this.kanji) {
        // We have switched to a new kanji, mayhap away from vocabulary, so we need to set these to be shown
        this.kanji = this.kanji_elem.innerText;
        this.showHanziWriter();
        return true;
      } else {
        return false;
      }
    }
    // The character content has switched to vocabulary or a radical
    this.kanji = null;
    this.hideHanziWriter();
    return false;
  }
  /**
   * Cleans up various objects if we switch away from them.
   */
  offReviewPage() {
    this.kanji = null;
    this.writer = null;
    this.container_div = null;
    if (this.observer) {
      this.observer.disconnect();
    }
  }
}

// Loads styles
GM_addStyle(`#wkhwa-container-div {
    position: relative;
    top: -32px;
    display: flex;
    align-items: center;
    justify-content: center;
}`);
let default_settings = {
  showOutline: true,
  showCharacter: true,
  width: 200,
  height: 200,
  padding: 20,
  strokeAnimationSpeed: 1,
  strokeHighlightSpeed: 2,
  strokeFadeDuration: 400,
  delayBetweenStrokes: 1000,
  delayBetweenLoops: 2000,
  strokeColor: "#555555",
  highlightColor: "#8899FF",
  outlineColor: "#FFFFFF",
  drawingColor: "#333333",
  drawingWidth: 20,
  showHintAfterMisses: 3,
  quizStartStrokeNum: 0,
  highlightOnComplete: false,
  quiz: true,
  animate: false,
  loop_animation: false
};
let config = {
  script_id: "wkhwa",
  title: "Hanzi writer addition",
  content: {
    hanzi_writer: {
      type: "group",
      label: "Options:",
      content: {
        showOutline: {
          type: 'checkbox',
          label: 'showOutline',
          default: default_settings.showOutline
        },
        showCharacter: {
          type: 'colorbox',
          label: 'showCharacter',
          default: default_settings.showCharacter
        },
        width: {
          type: "number",
          label: "width",
          default: default_settings.width
        },
        height: {
          type: "number",
          label: "height",
          default: default_settings.height
        },
        padding: {
          type: "number",
          label: "padding",
          default: default_settings.padding
        },
        strokeAnimationSpeed: {
          type: "number",
          label: "strokeAnimationSpeed",
          default: default_settings.strokeAnimationSpeed
        },
        strokeHighlightSpeed: {
          type: "number",
          label: "strokeHighlightSpeed",
          default: default_settings.strokeHighlightSpeed
        },
        strokeFadeDuration: {
          type: "number",
          label: "strokeFadeDuration",
          default: default_settings.strokeFadeDuration
        },
        delayBetweenStrokes: {
          type: "number",
          label: "delayBetweenStrokes",
          default: default_settings.delayBetweenStrokes
        },
        delayBetweenLoops: {
          type: "number",
          label: "delayBetweenLoops",
          default: default_settings.delayBetweenLoops
        },
        StrokeColor: {
          type: 'color',
          label: 'strokeColor',
          default: '#555555'
        },
        highlightColor: {
          type: 'color',
          label: 'highlightColor',
          default: '#8899FF'
        },
        outlineColor: {
          type: 'color',
          label: 'outlineColor',
          default: '#FFFFFF'
        },
        drawingColor: {
          type: 'color',
          label: 'drawingColor',
          default: '#333333'
        },
        drawingWidth: {
          type: "number",
          label: "drawingWidth",
          default: default_settings.drawingWidth
        },
        showHintAfterMisses: {
          type: "number",
          label: "showHintAfterMisses",
          default: default_settings.showHintAfterMisses
        },
        quizStartStrokeNum: {
          type: "number",
          label: "quizStartStrokeNum",
          default: default_settings.quizStartStrokeNum
        },
        highlightOnComplete: {
          type: 'checkbox',
          label: 'highlightOnComplete',
          default: default_settings.highlightOnComplete
        },
        quiz: {
          type: 'checkbox',
          label: 'quiz',
          default: default_settings.quiz
        },
        animate: {
          type: 'checkbox',
          label: 'animate',
          default: default_settings.animate
        },
        loop_animation: {
          type: 'checkbox',
          label: 'loopAnimation',
          default: default_settings.loop_animation
        }
      }
    }
  }
};
wkof.include('Menu, Settings');
wkof.ready('Menu, Settings').then(main);
function main() {
  wkof.Settings.load('wkhwa', default_settings);
  wkof.Menu.insert_script_link({
    name: 'wkhwa',
    submenu: 'wkhwa',
    title: 'Hanzi writer settings',
    on_click: new wkof.Settings(config).open
  });
  let review_page = new ReviewPage();
  new PageObserver(WK_PAGE.REVIEW, review_page.onReviewPage.bind(review_page), review_page.offReviewPage.bind(review_page));
}

})();