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.

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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();

})();