-
Notifications
You must be signed in to change notification settings - Fork 0
/
wanikani-mnemonic-images-script.js
308 lines (270 loc) · 10.9 KB
/
wanikani-mnemonic-images-script.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
// ==UserScript==
// @name WaniKani AI Mnemonic Images
// @namespace aimnemonicimages
// @version 1.9
// @description Adds AI images to radical, kanji, and vocabulary mnemonics.
// @author Sinyaven (modified by saraqael, aaron-meyers)
// @license MIT-0
// @match https://www.wanikani.com/*
// @match https://preview.wanikani.com/*
// @require https://greasyfork.org/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js
// @homepageURL https://community.wanikani.com/t/new-volunteer-project-were-using-ai-to-create-mnemonic-images-for-every-radical-kanji-vocabulary-come-join-us/58234
// @grant none
// ==/UserScript==
(async function () {
"use strict";
/* global $, wkItemInfo */
/* eslint no-multi-spaces: "off" */
//////////////
// settings //
//////////////
const ENABLE_RESIZE_BY_DRAGGING = true;
const USE_THUMBNAIL_FOR_REVIEWS = true;
const USE_THUMBNAIL_FOR_ITEMINF = false;
//////////////
if (!localStorage.getItem("AImnemonicMaxSize")) localStorage.setItem("AImnemonicMaxSize", 400); // standard size
const folderNames = {
radical: 'Radicals',
kanji: 'Kanji',
vocabulary: 'Vocabulary',
kanaVocabulary: 'KanaVocabulary',
}
function getUrls(wkId, type, mnemonic, thumb = false) {
// Prefer images specified in user notes if any
const urlsFromNote = extractUrlsFromNote(mnemonic, thumb);
return urlsFromNote ? urlsFromNote :
['https://wk-mnemonic-images.b-cdn.net/' + type + '/' + mnemonic + '/' + wkId + (thumb ? '-thumb.jpg' : '.png')];
}
function extractUrlsFromNote(mnemonic, thumb) {
const noteFrame = getNoteFrame(mnemonic);
if (!noteFrame) {
return null;
}
const note = noteFrame.innerText;
if (!note) {
return null;
}
const imageUrlRegex = /(http[s]?|[s]?ftp[s]?)(:\/\/)([^\s,]+\.(png|jpg|jpeg))/g;
if (!note.match(imageUrlRegex)) {
return null;
}
// Collapse the actual image URLs in the visible note to reduce visual clutter.
// This happens only once so editing the note (even if no changes are made) will
// restore the original URLs. This seems to be reasonable behavior.
const textContainer = noteFrame.getElementsByClassName('user-note__text')[0];
if (textContainer) {
var imageOrdinal = 1;
var reducedPrev = false;
for (var i = 0; i < textContainer.childNodes.length; i++) {
const n = textContainer.childNodes[i];
if (n.nodeType === Node.TEXT_NODE) {
// Replace image URL with [imageN]
const original = n.textContent;
const replaced = original.replace(imageUrlRegex, `[image${imageOrdinal}] `);
if (original !== replaced) {
n.textContent = replaced;
imageOrdinal++;
reducedPrev = true;
continue;
}
} else if (reducedPrev && n.nodeType === Node.ELEMENT_NODE && n.tagName === 'BR') {
// Remove the following line break so sequence of image URLs will collapse to single line
n.remove();
i--;
}
reducedPrev = false;
}
}
return [...note.matchAll(imageUrlRegex)].map(e => {
const url = new URL(e[0]);
// Respect thumbnail parameter for images from the community project
return (thumb && url.hostname === 'wk-mnemonic-images.b-cdn.net') ?
url.href.replace('.png', '-thumb.jpg') :
url.href;
});
}
function getNoteFrame(mnemonic) {
var noteElementName = null;
switch (mnemonic) {
case 'Meaning':
noteElementName = 'user_meaning_note';
break;
case 'Reading':
noteElementName = 'user_reading_note';
break;
}
if (!noteElementName) {
return null;
}
return window.document.getElementById(noteElementName);
}
function init() {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = 'https://cdn.jsdelivr.net/gh/aaron-meyers/wanikani-mnemonics/userscript/carousel.min.css';
document.head.appendChild(link);
// TODO implement left/right navigation buttons for carousel, will require more js
// (consider getting this from github rather than inlining in this script)
//var script = document.createElement("script");
//script.setAttribute("type", "text/javascript");
//script.setAttribute("src", "TODO");
//document.head.appendChild(script);
wkItemInfo.forType("radical,kanji,vocabulary,kanaVocabulary").under("meaning").append("Meaning Mnemonic Image", ({ id, type, on }) => artworkSection(id, type, 'Meaning', on));
wkItemInfo.forType("radical,kanji,vocabulary,kanaVocabulary").under("reading").append("Reading Mnemonic Image", ({ id, type, on }) => artworkSection(id, type, 'Reading', on));
}
async function artworkSection(subjectId, type, mnemonic, page) {
//console.log(`artworkSection ${subjectId} ${type} ${mnemonic}`);
const fullType = folderNames[type];
const isItemInfo = page === 'itemPage';
const useThumbnail = isItemInfo ? USE_THUMBNAIL_FOR_ITEMINF : USE_THUMBNAIL_FOR_REVIEWS;
// Wait until note frame is loaded so we can try to load images from notes
while (true) {
const noteFrame = getNoteFrame(mnemonic);
if (noteFrame) {
//console.log(`${mnemonic} noteFrame preload outerHTML ${noteFrame.outerHTML}`);
if (noteFrame.loaded)
await noteFrame.loaded;
if (noteFrame.complete) {
const subjectIdRegex = /subject_id=(\d+)/;
const subjectMatch = noteFrame.src.match(subjectIdRegex);
//console.log(`${mnemonic} noteFrame is loaded, subjectMatch ${subjectMatch[1]} outerHTML ${noteFrame.outerHTML}`);
if (subjectMatch && subjectMatch[1] == subjectId) {
// Expected subject is loaded so we can continue
//console.log(`subject match on ${subjectId}, breaking`);
break;
} else {
// Note frame has outdated subject, need to wait for new one to load
//console.log(`subject mismatch on ${subjectId} != ${subjectMatch[1]}, awaiting frame-load`);
}
} else {
// Note frame has not completed loading, need to wait
//console.log(`${mnemonic} noteFrame is not complete, awaiting frame-load`);
}
} else {
// Note frame has not been added yet, need to wait
//console.log(`${mnemonic} noteFrame is not found, awaiting frame-load`);
}
await new Promise(resolve => document.addEventListener('turbo:frame-load', resolve, { once: true }));
}
const imageUrls = getUrls(subjectId, fullType, mnemonic, useThumbnail); // get url (thumbnail in reviews and lessons)
if (imageUrls.length === 1) {
// Simple case for single image with support for resize
const imageUrl = imageUrls[0];
const image = await createAndLoadImg(imageUrl);
if (!image) return null;
if (ENABLE_RESIZE_BY_DRAGGING) {
const currentMax = parseInt(localStorage.getItem("AImnemonicMaxSize")) || 900;
makeMaxResizable(image, currentMax).afterResize(m => { localStorage.setItem("AImnemonicMaxSize", m); let e = new Event("storage"); e.key = "AImnemonicMaxSize"; e.newValue = m; dispatchEvent(e); });
addEventListener("storage", e => { if (e.key === "AImnemonicMaxSize") { image.style.maxWidth = `min(${e.newValue}px, 100%)`; image.style.maxHeight = e.newValue + "px"; } });
}
return image;
} else {
// Multiple images, use a carousel
// Derived from https://nolanlawson.com/2019/02/10/building-a-modern-carousel-with-css-scroll-snap-smooth-scrolling-and-pinch-zoom/
const viewport = document.createElement("div");
viewport.className = "viewport";
const carouselFrame = document.createElement("div");
carouselFrame.className = "carousel-frame";
const carousel = document.createElement("div");
carousel.className = "carousel";
const scrollList = document.createElement("ul");
scrollList.className = "scroll";
carousel.appendChild(scrollList);
carouselFrame.appendChild(carousel);
viewport.appendChild(carouselFrame);
for (const imageUrl of imageUrls) {
const image = await createAndLoadImg(imageUrl);
if (!image) continue;
image.className = "scroll-image";
const listItem = document.createElement("li");
listItem.className = "scroll-item-outer";
const itemDiv = document.createElement("div");
itemDiv.className = "scroll-item";
itemDiv.appendChild(image);
listItem.appendChild(itemDiv);
scrollList.appendChild(listItem);
}
return viewport;
}
}
async function createAndLoadImg(imageUrl) {
const image = document.createElement("img");
if (!(await new Promise(res => {
image.onload = () => res(true);
image.onerror = () => res(false);
image.src = imageUrl;
}))) return null;
return image;
}
function makeMaxResizable(element, currentMax, lowerBound = 200) {
let size = 0;
let max = currentMax;
let oldMax = currentMax;
let callback = () => { };
let pointers = [{ id: NaN, x: 0, y: 0 }]; // image origin is always a pointer (scaling center)
function getDistanceSum(e) {
removePointer(e);
addPointer(e);
function length(p1, p2) { let d = [p1.x - p2.x, p1.y - p2.y]; return Math.sqrt(d[0] * d[0] + d[1] * d[1]); }
return pointers.reduce((total, p1) => pointers.reduce((l, p2) => l + length(p1, p2), total), 0);
//return pointers.reduce(([len, lastP], p) => [len + length(lastP, p), p], [0, pointers[pointers.length - 1]])[0]; // old version using circumference - order dependent! => not usable if pointers.length > 3
};
function removePointer(e) {
if (e) pointers = pointers.filter(p => p.id !== e.pointerId);
}
function addPointer(e) {
if (!e) return;
let rect = element.getBoundingClientRect();
pointers.push({ id: e.pointerId, x: e.clientX - rect.left, y: e.clientY - rect.top });
}
function startResizing(e) {
if (e.button !== 0) return;
if (pointers.length < 2) {
max = parseFloat(element.style.maxHeight);
oldMax = max;
}
size = getDistanceSum(e);
element.addEventListener("pointermove", doResizing);
element.addEventListener("pointerup", endResizing);
element.addEventListener("pointercancel", cancelResizing);
element.setPointerCapture(e.pointerId);
e.preventDefault();
}
function doResizing(e) {
if (!(e.buttons & 1)) return;
let newSize = getDistanceSum(e);
max *= newSize / size;
size = newSize;
updateMax();
};
function endResizing(e) {
doResizing(e);
max = Math.min(max, element.parentElement.clientWidth, element.naturalWidth);
oldMax = Math.max(max, lowerBound);
cancelResizing(e);
callback(max);
}
function cancelResizing(e) {
removePointer(e);
size = getDistanceSum();
if (pointers.length > 1) return;
max = oldMax;
updateMax();
element.removeEventListener("pointermove", doResizing);
element.removeEventListener("pointerup", endResizing);
element.removeEventListener("pointercancel", cancelResizing);
element.releasePointerCapture(e.pointerId);
}
function updateMax() {
let m = Math.max(max, lowerBound);
element.style.maxWidth = `min(${m}px, 100%)`;
element.style.maxHeight = m + "px";
};
updateMax();
element.style.touchAction = "pan-x pan-y";
element.addEventListener("pointerdown", startResizing);
return { afterResize: f => { callback = f; } };
}
init();
})();