WaniKani Stroke Order + Last-Stroke Marker (KanjiVG)

Thumbnails per stroke (KanjiVG). Jisho Style. Previous strokes semi-opaque black, last stroke full black + red dot. SPA-safe, launches without reload.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         WaniKani Stroke Order + Last-Stroke Marker (KanjiVG)
// @namespace    https://wanikani.com
// @version      1.4
// @description  Thumbnails per stroke (KanjiVG). Jisho Style. Previous strokes semi-opaque black, last stroke full black + red dot. SPA-safe, launches without reload.
// @match        https://www.wanikani.com/*
// @author       NoahCha + ChatGPT
// @grant        none
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  /* -------------------------------
        CSS
  -------------------------------- */
  const css = `
.wk-sos-box {
  margin: 18px 0;
  padding: 12px;
  border-radius: 8px;
  border: 1px solid #e6e6e6;
  background: #fff;
  box-shadow: 0 1px 0 rgba(16,24,40,0.03);
}
.wk-sos-title {
  font-weight: 700;
  margin-bottom: 8px;
  font-size: 16px;
}
.wk-sos-grid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 6px;
}
.wk-sos-item {
  background: #fafafa;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  padding: 6px;
  text-align: center;
}
.wk-sos-thumb {
  width: 100%;
  height: auto;
  display:block;
}
.wk-sos-label {
  margin-top: 6px;
  font-size: 12px;
  color: #333;
}
.wk-sos-loading {
  font-size: 13px;
  color: #666;
}
`;
  const style = document.createElement('style');
  style.textContent = css;
  document.head.appendChild(style);

  /* -------------------------------
      HELPERS
  -------------------------------- */
  const getKanjiFromURL = () =>
    decodeURIComponent(location.pathname.replace('/kanji/', '')).match(/[\u4e00-\u9faf\u3400-\u4dbf]/)?.[0] || null;

  const findReadingsSection = () =>
    document.querySelector('[data-subject-section="readings"]')
    || Array.from(document.querySelectorAll('h2,h3'))
        .find(x => x.textContent.trim() === 'Readings')?.closest('.subject-section');

  const kanjiVGUrl = ch =>
    `https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${ch.codePointAt(0).toString(16).padStart(5,"0")}.svg`;

  const fetchText = async url => {
    const r = await fetch(url, { cache: "force-cache" });
    if (!r.ok) throw new Error("fetch failed");
    return r.text();
  };

  const extractStrokes = doc => {
    const svg = doc.querySelector("svg");
    if (!svg) return { viewBox:"0 0 109 109", strokes:[] };

    const viewBox = svg.getAttribute("viewBox") || "0 0 109 109";
    const groups = Array.from(svg.querySelectorAll('[id*="-s"]'));
    const map = new Map();

    for (let g of groups) {
      const m = g.id.match(/-s(\d+)$/);
      if (!m) continue;
      const n = parseInt(m[1]);
      if (!map.has(n)) map.set(n, []);
      map.get(n).push(g);
    }

    return {
      viewBox,
      strokes: [...map.entries()]
        .sort((a,b)=>a[0]-b[0])
        .map(([n,nodes]) => ({n, nodes}))
    };
  };

  const getPathStartCoordsFromD = d => {
    if (!d) return null;
    const m = d.match(/M\s*([\-0-9.]+)[,\s]+([\-0-9.]+)/i);
    if (m) return { x:parseFloat(m[1]), y:parseFloat(m[2]) };
    return null;
  };

  function buildMiniSVG(viewBox, prevHtml, lastHtml, lastPathD) {
    const start = getPathStartCoordsFromD(lastPathD);
    const dot = start ? `<circle cx="${start.x}" cy="${start.y}" r="3.5" fill="#ff3b30"/>` : '';
    return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke-linecap="round" stroke-linejoin="round">
  <g stroke="#000" stroke-opacity="0.5" stroke-width="2.3">${prevHtml}</g>
  <g stroke="#000" stroke-opacity="1"   stroke-width="2.8">${lastHtml}</g>
  ${dot}
</svg>`;
  }

  /* -------------------------------
        MAIN
  -------------------------------- */
  let lastKanji = null;

  async function generate(char) {
    const readings = findReadingsSection();
    if (!readings || readings.querySelector(".wk-sos-box")) return;

    const box = document.createElement("div");
    box.className = "wk-sos-box";
    box.innerHTML = `<div class="wk-sos-title">Stroke Order</div>
                     <div class="wk-sos-loading">Loading…</div>`;
    readings.prepend(box);

    let svgText = null;
    try {
      svgText = await fetchText(kanjiVGUrl(char));
    } catch {
      box.innerHTML = `<div class="wk-sos-title">Stroke Order</div>
                       <div class="wk-sos-loading">KanjiVG unavailable.</div>`;
      return;
    }

    const doc = new DOMParser().parseFromString(svgText, "image/svg+xml");
    const { viewBox, strokes } = extractStrokes(doc);
    if (!strokes.length) {
      box.innerHTML = `<div class="wk-sos-title">Stroke Order</div>
                       <div class="wk-sos-loading">No stroke data.</div>`;
      return;
    }

    const grid = document.createElement("div");
    grid.className = "wk-sos-grid";

    const urls = [];
    const accumulated = [];

    for (let i = 0; i < strokes.length; i++) {
      const step = strokes[i];
      for (const n of step.nodes) accumulated.push(n);

      const prev = accumulated.slice(0, accumulated.length - step.nodes.length);
      const last = step.nodes;

      const prevHtml = prev.map(p => {
        const c = p.cloneNode(true); c.removeAttribute("stroke"); c.removeAttribute("fill"); return c.outerHTML;
      }).join("");

      const lastHtml = last.map(p => {
        const c = p.cloneNode(true); c.removeAttribute("stroke"); c.removeAttribute("fill"); return c.outerHTML;
      }).join("");

      let lastD = null;
      for (let n of last)
        if (n.tagName.toLowerCase() === "path") { lastD = n.getAttribute("d"); break; }

      const svg = buildMiniSVG(viewBox, prevHtml, lastHtml, lastD);
      const url = URL.createObjectURL(new Blob([svg], {type:"image/svg+xml"}));
      urls.push(url);

      const div = document.createElement("div");
      div.className = "wk-sos-item";
      div.innerHTML = `<img class="wk-sos-thumb" src="${url}">
                       <div class="wk-sos-label">Step ${i+1}</div>`;
      grid.appendChild(div);
    }

    box.innerHTML = `<div class="wk-sos-title">Stroke Order</div>`;
    box.appendChild(grid);

    setTimeout(() => urls.forEach(u => URL.revokeObjectURL(u)), 60000);
  }

  /* -------------------------------
      AUTO-INJECT — RELIABLE VERSION
  -------------------------------- */
  function onURLChange() {
    const k = getKanjiFromURL();
    if (k && k !== lastKanji) {
      lastKanji = k;
      const check = setInterval(() => {
        const r = findReadingsSection();
        if (r) { clearInterval(check); generate(k); }
      }, 80);
      setTimeout(() => clearInterval(check), 3000);
    }
  }

  // Detect navigation changes
  document.addEventListener("turbo:load", onURLChange);
  window.addEventListener("popstate", onURLChange);

  const push = history.pushState;
  history.pushState = function () {
    push.apply(this, arguments);
    onURLChange();
  };

  // First page load
  onURLChange();

})();