WaniKani Stroke Order

Shows a kanji's stroke order on its page and during lessons and reviews.

当前为 2021-03-13 提交的版本,查看 最新版本

// ==UserScript==
// @name        WaniKani Stroke Order
// @namespace   japanese
// @version     1.1.8
// @description Shows a kanji's stroke order on its page and during lessons and reviews.
// @license     GPL version 3 or any later version; http://www.gnu.org/copyleft/gpl.html
// @include     http*://*wanikani.com/kanji/*
// @include     http*://*wanikani.com/level/*/kanji/*
// @include     http*://*wanikani.com/review/session
// @include     http*://*wanikani.com/lesson/session
// @author      Looki, maintained by Kumirei
// @grant       GM_xmlhttpRequest
// @connect     jisho.org
// @connect     cloudfront.net
// @require     http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/snap.svg/0.5.1/snap.svg-min.js
// ==/UserScript==

/*
 * Thanks a lot to ...
 * Wanikani Phonetic-Semantic Composition - Userscript
 * by ruipgpinheiro (LordGravewish)
 * ... for code showing me how to insert sections during kanji reviews.
 * The code heavily borrows from that script!
 * Also thanks to Halo for a loading bug fix!
 */

;(function () {
    /*
     * Helper Functions/Variables
     */
    $ = unsafeWindow.$

    /*
     * Global Variables/Objects/Classes
     */
    var PageEnum = Object.freeze({ unknown: 0, kanji: 1, reviews: 2, lessons: 3 })
    var curPage = PageEnum.unknown
    var JISHO = 'https://jisho.org'
    var strokeOrderCss =
        "<style type='text/css'>" +
        '.stroke_order_diagram--bounding_box {fill: none;stroke: #ddd; stroke-width: 2; stroke-linecap: square;stroke-linejoin: square;}' +
        '.stroke_order_diagram--bounding_box {fill: none;stroke: #ddd; stroke-width: 2;stroke-linecap: square;stroke-linejoin: square;}' +
        '.stroke_order_diagram--existing_path {fill: none;stroke: #aaa;stroke-width: 3;stroke-linecap: round;stroke-linejoin: round;}' +
        '.stroke_order_diagram--current_path {fill: none;stroke: #000;stroke-width: 3; stroke-linecap: round; stroke-linejoin: round;}' +
        '.stroke_order_diagram--path_start {fill: rgba(255,0,0,0.7);stroke: none;}' +
        '.stroke_order_diagram--guide_line {fill: none; stroke: #ddd;stroke-width: 2;stroke-linecap: square; stroke-linejoin: square; stroke-dasharray: 5, 5;}</style>'

    /*
     * Main
     */
    function init() {
        // Determine page type
        if (/\/kanji\/./.test(document.URL)) {
            curPage = PageEnum.kanji
        } else if (/\/review/.test(document.URL)) {
            curPage = PageEnum.reviews
        } else if (/\/lesson/.test(document.URL)) {
            curPage = PageEnum.lessons
        }

        // Create and store the element that will hold the image
        unsafeWindow.diagram = createDiagramSection()

        // Register callback for when to load stroke order
        switch (curPage) {
            case PageEnum.kanji:
                loadDiagram()
                break
            case PageEnum.reviews:
                var o = new MutationObserver(function (mutations) {
                    // The last one always has 2 mutations, so let's use that
                    if (mutations.length != 2) return

                    // Reviews dynamically generate the DOM. We always need to re-insert the element
                    if (getKanji() !== null) {
                        setTimeout(function () {
                            var diagram = createDiagramSection()
                            if (diagram !== null && diagram.length > 0) {
                                unsafeWindow.diagram = diagram
                                loadDiagram()
                            }
                        }, 150)
                    }
                })
                o.observe(document.getElementById('item-info'), { attributes: true })
                break
            case PageEnum.lessons:
                o = new MutationObserver(loadDiagram)
                o.observe(document.getElementById('supplement-kan'), { attributes: true })
                loadDiagram()
                break
        }
    }

    if (document.readyState === 'complete') {
        init()
    } else {
        window.addEventListener('load', init)
    }

    /*
     * Returns the current kanji
     */
    function getKanji() {
        switch (curPage) {
            case PageEnum.kanji:
                return document.title[document.title.length - 1]

            case PageEnum.reviews:
                var curItem = $.jStorage.get('currentItem')
                if ('kan' in curItem) return curItem.kan.trim()
                else return null
                break
            case PageEnum.lessons:
                var kanjiNode = $('#character')

                if (kanjiNode === undefined || kanjiNode === null) return null

                return kanjiNode.text().trim()
        }

        return null
    }

    /*
     * Creates a section for the diagram and returns a pointer to its content
     */
    function createDiagramSection() {
        // Reviews hack: Only do it once
        if ($('#stroke_order').length == 0) {
            let sectionHTML =
                '<section><h2>Stroke Order</h2><div style="width:100%;overflow-x: auto; overflow-y: hidden"><svg id="stroke_order"></svg></div></section>'

            switch (curPage) {
                case PageEnum.kanji:
                    $(sectionHTML).insertAfter('.span12 header')
                    break
                case PageEnum.reviews:
                    console.log('prepend')
                    $('#item-info-col2').prepend(sectionHTML)
                    break
                case PageEnum.lessons:
                    $('#supplement-kan-breakdown .col1').append(sectionHTML)
                    break
            }
            $(strokeOrderCss).appendTo('head')
        }

        return $('#stroke_order').empty()
    }

    /*
     * Adds the diagram section element to the appropriate location
     */
    function loadDiagram() {
        if (!unsafeWindow || !unsafeWindow.diagram.length) return

        diagram.empty()
        setTimeout(function () {
            GM_xmlhttpRequest({
                method: 'GET',
                url: new URL(JISHO + '/search/' + getKanji() + '%20%23kanji'),
                onload: function (xhr) {
                    var diagram = unsafeWindow.diagram
                    if (xhr.status == 200) {
                        var strokeOrderSvg = xhr.responseText.match(/var url = '\/\/(.+)';/)
                        if (strokeOrderSvg) {
                            GM_xmlhttpRequest({
                                method: 'GET',
                                url: new URL('https://' + strokeOrderSvg[1]),
                                onload: function (xhr) {
                                    diagram.empty()
                                    new strokeOrderDiagram(diagram.get(0), $.parseXML(xhr.responseText, 'xml'))
                                },
                                onerror: function (xhr) {
                                    unsafeWindow.diagram.html('Error while loading diagram')
                                },
                            })
                        }
                    } else {
                        console.error(xhr.responseText)
                        unsafeWindow.diagram.html('Error while loading diagram ')
                    }
                },
                onerror: function (xhr) {
                    console.error(xhr.responseText)
                    unsafeWindow.diagram.html('Error while loading diagram')
                },
            })
        }, 0)
    }

    /*
     * Lifted from jisho.org
     */
    var strokeOrderDiagram = function (element, svgDocument) {
        var s = Snap(element)
        var diagramSize = 200
        var coordRe = '(?:\\d+(?:\\.\\d+)?)'
        var strokeRe = new RegExp('^[LMT]\\s*(' + coordRe + ')[,\\s](' + coordRe + ')', 'i')
        var f = Snap(svgDocument.getElementsByTagName('svg')[0])
        var allPaths = f.selectAll('path')
        var drawnPaths = []
        var canvasWidth = (allPaths.length * diagramSize) / 2
        var canvasHeight = diagramSize / 2
        var frameSize = diagramSize / 2
        var frameOffsetMatrix = new Snap.Matrix()
        frameOffsetMatrix.translate(-frameSize / 16 + 2, -frameSize / 16 + 2)

        // Set drawing area
        s.node.style.width = canvasWidth + 'px'
        s.node.style.height = canvasHeight + 'px'
        s.node.setAttribute('viewBox', '0 0 ' + canvasWidth + ' ' + canvasHeight)

        // Draw global guides
        var boundingBoxTop = s.line(1, 1, canvasWidth - 1, 1)
        var boundingBoxLeft = s.line(1, 1, 1, canvasHeight - 1)
        var boundingBoxBottom = s.line(1, canvasHeight - 1, canvasWidth - 1, canvasHeight - 1)
        var horizontalGuide = s.line(0, canvasHeight / 2, canvasWidth, canvasHeight / 2)
        boundingBoxTop.attr({ class: 'stroke_order_diagram--bounding_box' })
        boundingBoxLeft.attr({ class: 'stroke_order_diagram--bounding_box' })
        boundingBoxBottom.attr({ class: 'stroke_order_diagram--bounding_box' })
        horizontalGuide.attr({ class: 'stroke_order_diagram--guide_line' })

        // Draw strokes
        var pathNumber = 1
        allPaths.forEach(function (currentPath) {
            var moveFrameMatrix = new Snap.Matrix()
            moveFrameMatrix.translate(frameSize * (pathNumber - 1) - 4, -4)

            // Draw frame guides
            var verticalGuide = s.line(
                frameSize * pathNumber - frameSize / 2,
                1,
                frameSize * pathNumber - frameSize / 2,
                canvasHeight - 1,
            )
            var frameBoxRight = s.line(frameSize * pathNumber - 1, 1, frameSize * pathNumber - 1, canvasHeight - 1)
            verticalGuide.attr({ class: 'stroke_order_diagram--guide_line' })
            frameBoxRight.attr({ class: 'stroke_order_diagram--bounding_box' })

            // Draw previous strokes
            drawnPaths.forEach(function (existingPath) {
                var localPath = existingPath.clone()
                localPath.transform(moveFrameMatrix)
                localPath.attr({ class: 'stroke_order_diagram--existing_path' })
                s.append(localPath)
            })

            // Draw current stroke
            currentPath.transform(frameOffsetMatrix)
            currentPath.transform(moveFrameMatrix)
            currentPath.attr({ class: 'stroke_order_diagram--current_path' })
            s.append(currentPath)

            // Draw stroke start point
            var match = strokeRe.exec(currentPath.node.getAttribute('d'))
            var pathStartX = match[1]
            var pathStartY = match[2]
            var strokeStart = s.circle(pathStartX, pathStartY, 4)
            strokeStart.attr({ class: 'stroke_order_diagram--path_start' })
            strokeStart.transform(moveFrameMatrix)

            pathNumber++
            drawnPaths.push(currentPath.clone())
        })
    }
})()