WaniKani Stroke Order (Jisho-style steps, KanjiVG)

Insert per-stroke thumbnails (Jisho-style) from KanjiVG into WaniKani kanji pages, before Readings section (SPA compatible). Stroke-only icons, 4 per row.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         WaniKani Stroke Order (Jisho-style steps, KanjiVG)
// @namespace    https://wanikani.com
// @version      1.1
// @description  Insert per-stroke thumbnails (Jisho-style) from KanjiVG into WaniKani kanji pages, before Readings section (SPA compatible). Stroke-only icons, 4 per row.
// @match        https://www.wanikani.com/kanji/*
// @author       NoahCha + ChatGPT
// @grant        none
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';
  console.log('[WK StrokeOrderSteps] loaded');

  /* ------------------------------
     Styling
  --------------------------------*/
  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); /* ⬅ vignettes 2× plus petites */
  gap: 6px;
  align-items: start;
}
.wk-sos-item {
  background: #fafafa;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  padding: 6px;
  display: flex;
  flex-direction: column;
  align-items: 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 s = document.createElement('style');
  s.textContent = css;
  document.head.appendChild(s);

  /* ------------------------------
     Helpers
  --------------------------------*/
  function kanaOrKanjiFromURL() {
    const part = decodeURIComponent(window.location.pathname.replace(/^\/kanji\//, ''));
    if (!part) return null;
    for (const ch of part) {
      if (/[\u4e00-\u9faf\u3400-\u4dbf]/.test(ch)) return ch;
    }
    return part.length === 1 ? part : part[0] || null;
  }

  function findReadingsSectionContainer() {
    const headings = Array.from(document.querySelectorAll('h2, h3'));
    for (const h of headings) {
      const t = (h.textContent || '').trim();
      if (/^Readings$/i.test(t) || /\bReadings\b/i.test(t)) {
        const candidate = h.closest('.subject-section, .subject-section__section, .subject-section_content') || h.parentElement;
        if (candidate) return candidate;
      }
    }
    const byAttr = document.querySelector('[data-subject-section="readings"]');
    if (byAttr) return byAttr;

    const all = Array.from(document.querySelectorAll('section, div'));
    for (const el of all) {
      if ((el.textContent || '').trim().startsWith('Readings')) return el;
    }
    return null;
  }

  function alreadyInserted(char) {
    return !!document.querySelector('.wk-sos-box[data-kanji="' + char + '"]');
  }

  function kanjivgUrlFor(char) {
    const cp = char.codePointAt(0);
    const hex = cp.toString(16).padStart(5, '0').toLowerCase();
    return `https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`;
  }

  async function fetchText(url) {
    const res = await fetch(url, { cache: 'force-cache' });
    if (!res.ok) throw new Error('fetch failed ' + res.status);
    return await res.text();
  }

  function extractStrokeElements(svgDoc) {
    const svgEl = svgDoc.querySelector('svg');
    if (!svgEl) return { viewBox: null, strokes: [] };

    const viewBox =
      svgEl.getAttribute('viewBox') ||
      svgEl.getAttribute('viewbox') ||
      '0 0 109 109';

    const candidates = Array.from(svgEl.querySelectorAll('[id]'));
    const strokeNodes = candidates
      .map((el) => {
        const id = el.getAttribute('id') || '';
        const m = id.match(/-s(\d+)$/);
        if (m) return { node: el, n: parseInt(m[1], 10) };
        return null;
      })
      .filter(Boolean)
      .sort((a, b) => a.n - b.n)
      .map((o) => ({ node: o.node, n: o.n }));

    if (strokeNodes.length === 0) {
      const paths = Array.from(svgEl.querySelectorAll('path, g, polyline, line'));
      return {
        viewBox,
        strokes: paths.map((p, i) => ({ node: p, n: i + 1 }))
      };
    }

    const strokesMap = new Map();
    strokeNodes.forEach(({ node, n }) => {
      if (!strokesMap.has(n)) strokesMap.set(n, []);
      strokesMap.get(n).push(node);
    });

    const strokes = Array.from(strokesMap.entries()).map(([n, nodes]) => ({ n, nodes }));
    return { viewBox, strokes };
  }

  /* ------------------------------
     NEW : build mini-SVG with stroke only
  --------------------------------*/
  function buildMiniSVG(viewBox, nodesHtml) {
    // wrap all nodes into a single SVG
    let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" stroke="currentColor" stroke-width="2.5" fill="none">${nodesHtml}</svg>`;
    return svg;
  }

  function createThumbnailFromSVGString(svgString) {
    const blob = new Blob([svgString], { type: 'image/svg+xml' });
    return URL.createObjectURL(blob);
  }

  function cleanupObjectURLs(urls) {
    setTimeout(() => {
      urls.forEach((u) => {
        try { URL.revokeObjectURL(u); } catch (e) {}
      });
    }, 60 * 1000);
  }

  /* ------------------------------
     Main
  --------------------------------*/
  async function generateAndInsert(char, readingsSection) {
    try {
      if (!char || !readingsSection) return false;
      if (alreadyInserted(char)) return true;

      const box = document.createElement('div');
      box.className = 'wk-sos-box';
      box.setAttribute('data-kanji', char);
      box.innerHTML = `<div class="wk-sos-title">Stroke Order</div>
                       <div class="wk-sos-loading">Loading stroke steps…</div>`;
      readingsSection.parentNode.insertBefore(box, readingsSection);

      const svgUrl = kanjivgUrlFor(char);
      let svgText;
      try {
        svgText = await fetchText(svgUrl);
      } catch (err) {
        console.warn('[WK StrokeOrderSteps] KanjiVG fetch failed', err);
        box.innerHTML = `<div class="wk-sos-title">Stroke Order</div><div class="wk-sos-loading">Stroke diagram not available (KanjiVG missing).</div>`;
        return false;
      }

      const parser = new DOMParser();
      const doc = parser.parseFromString(svgText, 'image/svg+xml');

      const { viewBox, strokes } = extractStrokeElements(doc);
      if (!strokes || strokes.length === 0) {
        box.innerHTML = `<div class="wk-sos-title">Stroke Order</div><div class="wk-sos-loading">No stroke data found in SVG.</div>`;
        return false;
      }

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

      const objectUrls = [];
      let cumulativeHtml = '';

      for (let i = 0; i < strokes.length; i++) {
        const step = strokes[i];

        for (const node of step.nodes) {
          cumulativeHtml += node.outerHTML;
        }

        const mini = buildMiniSVG(viewBox, cumulativeHtml);
        const url = createThumbnailFromSVGString(mini);
        objectUrls.push(url);

        const item = document.createElement('div');
        item.className = 'wk-sos-item';

        const img = document.createElement('img');
        img.className = 'wk-sos-thumb';
        img.src = url;
        img.alt = `Step ${i + 1}`;

        const label = document.createElement('div');
        label.className = 'wk-sos-label';
        label.textContent = `Step ${i + 1}`;

        item.appendChild(img);
        item.appendChild(label);
        grid.appendChild(item);
      }

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

      cleanupObjectURLs(objectUrls);

      console.log('[WK StrokeOrderSteps] inserted', char, 'steps:', strokes.length);
      return true;
    } catch (e) {
      console.error('[WK StrokeOrderSteps] generation error', e);
      return false;
    }
  }

  /* ------------------------------
     SPA-safe injection
  --------------------------------*/
  let lastKanji = null;
  async function tryInject() {
    const char = kanaOrKanjiFromURL();
    if (!char) return;

    if (char === lastKanji && alreadyInserted(char)) return;
    const readingsSection = findReadingsSectionContainer();
    if (!readingsSection) return;

    lastKanji = char;
    await generateAndInsert(char, readingsSection);
  }

  const mo = new MutationObserver(() => {
    tryInject();
  });
  mo.observe(document.body, { childList: true, subtree: true });

  (function () {
    const _push = history.pushState;
    history.pushState = function () {
      _push.apply(this, arguments);
      setTimeout(() => tryInject(), 350);
    };
    window.addEventListener('popstate', () => setTimeout(() => tryInject(), 350));
  })();

  setTimeout(() => tryInject(), 700);
})();