WaniKani Stroke Order

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

目前為 2021-03-13 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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())
        })
    }
})()