Insert per-stroke thumbnails (Jisho-style) from KanjiVG into WaniKani kanji pages, before Readings section (SPA compatible). Stroke-only icons, 4 per row.
// ==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);
})();