diff --git a/README.md b/README.md index 68a8b60..5c56c94 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ - + ## :shrug: A google chrome extension for practising kanji -You can practise drawing kanji featured in the Wakatta units. That's pretty much it right now. +You can practise drawing kanji featured in the Wakatta units. It also has other useful information like character readings and example words. > [Install on the google chrome webstore](https://chrome.google.com/webstore/detail/kanjithing/nccfelhkfpbnefflolffkclhenplhiab) ## :gear: Installation > The extension is available in the [google chrome store](https://chrome.google.com/webstore/detail/nccfelhkfpbnefflolffkclhenplhiab), though it can be installed from source with the following instructions -1. [Download the repository as a zip](https://github.com/aiden2480/kanjithing/zipball/main) and extract +1. `git clone https://github.com/aiden2480/kanjithing` 2. Navigate to [`chrome://extensions`](chrome://extensions) 3. Ensure that the `Developer mode` switch is enabled in the top right of your screen 4. Click `Load unpacked` in the top left corner of the screen @@ -24,47 +24,18 @@ You can practise drawing kanji featured in the Wakatta units. That's pretty much ## :recycle: Update Google chrome will automatically update the extension as I publish new updates if you install from the chrome store. -If installing from this repository, `git clone` this repository and then run `git pull origin master` yourself. -You'll need to go back into the [chrome extension settings](chrome://extensions) and press the refresh icon next to the extension to reload. +If installing from this repository, run `git pull origin main`, then go back to `chrome://extensions` and press the refresh icon next to the extension to reload. I'm using a [custom GitHub action](.github/workflows/updatewebstore.yml) to automatically publish new versions of the extension to the chrome store every time I change the `version` parameter in the `manifest.json` file. -## :file_cabinet: API -The kanji drawing guide videos are sourced from [KanjiAlive](https://app.kanjialive.com/api/docs), and cached using a [middleman I created](https://replit.com/@chocolatejade42/kanjithing-backend) (as RapidAPI limits requests). -It also collates some data about the kanji which is used in the extension, such as readings, and example words. -Requests can be made at the `/kanji/:kanji` endpoint, like so. - -```bash -$ curl -L http://kanjithing-backend.chocolatejade42.repl.co/kanji/車 -``` -```json -{ - "status": 200, - "kanji": "車", - "kstroke": 7, - "kmeaning": "vehicle, wheel, car", - "kgrade": 1, - "kunyomi_ja": "くるま", - "onyomi_ja": "シャ", - "video": "https://media.kanjialive.com/kanji_animations/kanji_mp4/kuruma_00.mp4", - "examples": [ - ["車(くるま)", "car"], - ["電車(でんしゃ)", "train"], - ["自転車(じてんしゃ)", "bicycle"], - ["自動車(じどうしゃ)", "automobile"], - ["車いす(くるまいす)", "wheel chair"], - ["駐車する(ちゅうしゃする)", "park a car"], - ["停車する(ていしゃする)", "stop a car or train"] - ] -} -``` +## :camera_flash: Program screenshots + + + ## :memo: Future features - [ ] Able to star/favourite kanji to add them quickly to a custom set. - [ ] Make the user guess readings of kanji in words -- [ ] Help page - - How to use the extention, info about tooltips, etc - - Open on the first install - [ ] Flashcard thing where you get the meaning of the kanji and sample words and have to draw it - [ ] Custom flashcards to remember kanji/words - Import from quizlet @@ -77,20 +48,12 @@ $ curl -L http://kanjithing-backend.chocolatejade42.repl.co/kanji/車 - [ ] Right click to remove drawing (all connected strokes) - [ ] Add tooltip banner when extension updates - Potentially as a small subtext badge? -- [x] Keybinds to navigate the application via keyboard - - Up/down arrow to navigate between kanji sets - - Left/right arrow to navigate between kanji in the currently selected set - - R to select a random kanji in the currently selected set - - Enter to grade kanji drawing - - Space to play/pause/replay video guide - - Backspace to clear drawing - - S to star/unstar selected kanji - - Keybinds visible in tooltips - [ ] Use static assets for the emojis to keep design consistent between operating systems - [ ] Event listener on the popup script to determine when the set storage has changed - [ ] Use data from the KanjiAlive API to do pronuncation/sounds -- [ ] Make CSS for buttons/inputs be consistent throughout the popup/settings/index pages +- [x] Make CSS for buttons/inputs be consistent throughout the popup/settings/index pages - [ ] Fix overlap interaction with especially long word descriptions (同 kanji) - [x] Use a RapidAPI key in the application to fetch data (Replit downtime) -- [ ] Unspaghettify everything -- [ ] Add method of actually accessing the tips page +- [ ] Display notice if character data not available +- [ ] https://github.com/gildas-lormeau/zip.js/tree/master/dist +- [ ] https://github.com/kanjialive/kanji-data-media/blob/master/kanji-animations/animations-mp4.zip diff --git a/background.js b/background.js index 482208f..056affd 100644 --- a/background.js +++ b/background.js @@ -1,5 +1,4 @@ -/* Save the current kanji */ -var current; +/* Default sets for the initial installation */ var defaultsets = [ {"Unit one": "学校名前父母生高姉妹兄弟住所色"}, {"Unit two": "好同手紙英語何年私友行毎教場"}, @@ -51,14 +50,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {(async case "resetKanjiSets": await chrome.storage.local.remove("customsets"); await createKanjiSets(); - sendResponse(); break; case "ensureDefaultConfig": await ensureDefaultConfiguration(); - sendResponse(); break; } + sendResponse(); })(); return true}); /* Set up a listener for when the extension is installed/chrome restarts */ @@ -71,9 +69,10 @@ chrome.runtime.onInstalled.addListener(async reason => { await chrome.storage.local.set({ selectedkanji: 0 }); } - if ((await chrome.management.getSelf()).installType !== "development") - chrome.runtime.setUninstallURL("https://kanjithing-backend.chocolatejade42.repl.co/uninstall"); - + if (!await isDevelopment()) { + chrome.runtime.setUninstallURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + } + // Register context menus chrome.contextMenus.removeAll(() => { generateContextMenus(); @@ -92,7 +91,9 @@ chrome.runtime.onStartup.addListener(async () => { chrome.storage.onChanged.addListener(async (changes, namespace) => { // Console log when storage values change - if ((await chrome.management.getSelf()).installType !== "development") return; + if (!await isDevelopment()) { + return; + } for (let [key, { oldValue, newValue }] of Object.entries(changes)) { console.debug(`${key} : ${oldValue} -> ${newValue}`); @@ -107,14 +108,14 @@ async function ensureCorrectKanjiIcon() { var { customsets, selectedset, selectedkanji } = await chrome.storage.local.get(); if ([ customsets, selectedset, selectedkanji ].includes(undefined)) return; - setBrowserIcon(customsets[selectedset].kanji[selectedkanji], bypass=true); + setBrowserIcon(customsets[selectedset].kanji[selectedkanji]); } /** * Ensures the "Beta" badge is displayed if necessary */ async function ensureBetaBadge() { - if ((await chrome.management.getSelf()).installType === "development") { + if (await isDevelopment()) { chrome.action.setBadgeText({ text: "B" }); chrome.action.setBadgeBackgroundColor({ color: "#304db6" }); } @@ -126,24 +127,27 @@ async function ensureBetaBadge() { */ async function ensureDefaultConfiguration() { // Create default sets - var sets = (await chrome.storage.local.get("customsets")).customsets; - (sets === undefined) && await createKanjiSets(); + var { customsets } = await chrome.storage.local.get("customsets"); + + if (customsets === undefined) { + await createKanjiSets(); + } // Create default settings var { config } = await chrome.storage.local.get("config"); - (config === undefined) && await createDefaultConfig(); + + if (config === undefined) { + await createDefaultConfig(); + } } /** * Sets the browser icon to the currently selected character * * @param {Char} kanji The character to set the browser icon to - * @param {Boolean} bypass Bypass same-kanji check */ -function setBrowserIcon(kanji, bypass=false) { +function setBrowserIcon(kanji) { // https://jsfiddle.net/1u37ovj9/ - if (current === kanji && !bypass) return; - var canvas = new OffscreenCanvas(64, 64); var context = canvas.getContext("2d"); @@ -157,7 +161,6 @@ function setBrowserIcon(kanji, bypass=false) { context.fillStyle = "#FFFAFA"; context.fillText(kanji, 0.5 * canvas.width, 0.825 * canvas.height); - current = kanji; var imageData = context.getImageData(0, 0, 64, 64); chrome.action.setIcon({ imageData }, () => console.log(`Set browser icon to %c${kanji}`, "color: #7289da")); } @@ -238,21 +241,21 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => { // Show a little x for a second if an error occured if (!match) return await displayBadge(tab, "X", "#D9381E", 3000); - var sets = (await chrome.storage.local.get("customsets")).customsets; + var { customsets } = await chrome.storage.local.get("customsets"); if (info.menuItemId === "createnewset") { - sets.push({ - id: sets.slice(-1)[0].id + 1, + customsets.push({ + id: customsets.slice(-1)[0].id + 1, name: "Unnamed set", kanji: match, enabled: true }); - await chrome.storage.local.set({ customsets: sets }); + await chrome.storage.local.set({ customsets }); await displayBadge(tab, "✓", "#32CD32", 3000); if (isPopup) await chrome.storage.local.set({ - selectedunit: sets.at(-1).id, + selectedunit: customsets.at(-1).id, selectedkanji: 0, }); } @@ -260,10 +263,10 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => { if (info.menuItemId.startsWith("addtoset")) { var setid = info.menuItemId.match(/addtoset(.+)/)[1]; - var set = sets.find(x => x.id == setid); + var set = customsets.find(x => x.id == setid); set.kanji += match; - await chrome.storage.local.set({ customsets: sets }); + await chrome.storage.local.set({ customsets }); await displayBadge(tab, "✓", "#32CD32", 3000); if (isPopup) await chrome.storage.local.set({ @@ -303,3 +306,13 @@ async function displayBadge(tab, text, colour, milliseconds) { await chrome.action.setBadgeText({ text: current.text }); }, milliseconds); } + +/** + * + * @returns A boolean indicating if the current installation context is dev + */ +async function isDevelopment() { + var { installType } = await chrome.management.getSelf(); + + return installType === "development"; +} diff --git a/css/popup.css b/css/popup.css index 6fe98a8..c69c5b3 100644 --- a/css/popup.css +++ b/css/popup.css @@ -48,6 +48,7 @@ div#panel * { background-color: #36393e; color: #7289da; border: 2px solid #7289da; + cursor: pointer; } #panel > select { height: 23px; } diff --git a/js/utilities.js b/js/utilities.js index 9d15775..123898f 100644 --- a/js/utilities.js +++ b/js/utilities.js @@ -1,92 +1,39 @@ /** - * Takes in a video URL and gets the last frame from that video. - * Used to compare video to canvas drawing via Rembrandt. - * - * @param {URL} url The URL (data or otherwise) of a video resource - * @returns {DataURL} The last frame of that video as a data URL - */ -function getLastFrameOfVideo(url) { - return new Promise(async (resolve, reject) => { - var video = document.createElement("video"); - var fabcan = document.createElement("canvas"); - var fabctx = fabcan.getContext("2d"); - - video.addEventListener("error", reject); - video.addEventListener("loadedmetadata", () => { - fabcan.width = video.videoWidth; - fabcan.height = video.videoHeight; - - video.addEventListener("seeked", () => { - fabctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); - - resolve(fabcan.toDataURL()); - }); - - video.currentTime = video.duration; - }); - - video.src = await contentURLToDataURL(url); - video.load(); - }); -} - -/** - * Resizes an SVG from its original size to 248x248 + * Resizes an square SVG and returns the new data URL * * @param {DataURL} dataURL The data URL of the SVG - * @returns {DataURL} The resized element + * @returns {Canvas} The resized element on a canvas */ -function resizeSVG(dataURL) { +function resizeSquareSVGToCanvas(dataURL, sideLength) { return new Promise((resolve, reject) => { var fabcan = document.createElement("canvas"); var fabctx = fabcan.getContext("2d"); var img = new Image(); img.addEventListener("load", () => { - fabctx.drawImage(img, 0, 0, 248, 248); - resolve(fabcan.toDataURL()); + fabctx.drawImage(img, 0, 0, sideLength, sideLength); + resolve(fabcan); }) + fabcan.width = sideLength; + fabcan.height = sideLength; img.src = dataURL; - fabcan.width = 248; - fabcan.height = 248; - }); -} - -/** - * Takes a content URL and fully downloads the content, - * before converting it back to a data URL - * - * @param {URL} url The URL of a resource on the internet - * @returns {DataURL} The downloaded resource - */ -function contentURLToDataURL(url) { - return new Promise(async (resolve, reject) => { - var resp = await fetch(url, {cache: "force-cache"}); - var reader = new FileReader(); - - reader.addEventListener("load", () => { - // console.debug(reader.result); - resolve(reader.result); - }); - - reader.addEventListener("error", reject); - reader.readAsDataURL(await resp.blob()); }); } /** * Converts a canvas to black and white for better comparison * - * @param {HTMLCanvas} canvas The canvas element to process - * @returns {HTMLCanvas} The monochrome canvas + * @param {Canvas} canvas The canvas element to process + * @returns {Canvas} The monochrome canvas */ function convertCanvasToBlackAndWhite(canvas) { var pixels = canvas.getContext("2d").getImageData(0, 0, 248, 248); var fabcan = document.createElement("canvas"); var fabctx = fabcan.getContext("2d"); - [fabcan.width, fabcan.height] = [248, 248]; + fabcan.width = 248; + fabcan.height = 248; for (var y=0; y < pixels.height; y++) { for (var x=0; x < pixels.width; x++) { @@ -102,7 +49,38 @@ function convertCanvasToBlackAndWhite(canvas) { } fabctx.putImageData(pixels, 0, 0, 0, 0, pixels.width, pixels.height); - return fabcan.toDataURL("image/png") + return fabcan; +} + +/** + * Because cors is one of the dumbest things humans have ever invented + * + * @param {URL} url The url to request via cors proxy + * @returns The url page contents + */ +async function fetchUrlViaCorsProxy(url) { + var resp = await fetch(`https://api.allorigins.win/get?url=${encodeURIComponent(url)}`); + var json = await resp.json(); + + return json.contents; +} + +/** + * Creates a new blank canvas of dimensons widthxheight + * + * @param {Integer} width The width of the canvas + * @param {Integer} height The height of the canvas + */ +function newBlankCanvas(width, height) { + var canvas = document.createElement("canvas"); + var ctx = canvas.getContext("2d"); + + canvas.width = width; + canvas.height = height; + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, width, height); + + return canvas; } /** @@ -110,53 +88,39 @@ function convertCanvasToBlackAndWhite(canvas) { * compares them to evaluate the user's drawing. This process factors * in the complexity of the character when grading. * - * @returns {Float} The user's score as a percentage between 0 and 1 + * @returns {Float} The user's score between 0 and 100 */ export async function checkRembrandt() { var kanji = selectedkanji.selectedOptions[0].innerText; - // Replit backend method - var kanjiID = (await fetchKanjiDetails(kanji)).kanji.video.mp4.split("/").at(-1); - var comparison = await getLastFrameOfVideo("https://kanjithing-backend.chocolatejade42.repl.co/video/" + kanjiID); - - // SVG data URL method (broken) - // var kanjisvg = (await fetchKanjiDetails(kanji)).kanji.video.poster; - // var kanjisvgid = kanjisvg.split("/").at(-1); - // var svgBase64 = await contentURLToDataURL("https://kanjithing-backend.chocolatejade42.repl.co/svg/" + kanjisvgid); - // var comparison = await resizeSVG(svgBase64); - - var blankcanv = document.createElement("canvas"); - var blankctx = blankcanv.getContext("2d"); - - // Draw 248x248 white on a canvas - [blankcanv.width, blankcanv.height] = [248, 248]; - blankctx.fillStyle = "white"; - blankctx.fillRect(0, 0, 248, 248); - + var kanjiDetails = await fetchKanjiDetails(kanji); + var posterUrl = kanjiDetails.kanji.video.poster; + var posterDataUrl = await fetchUrlViaCorsProxy(posterUrl); + var resizedPosterCanvas = await resizeSquareSVGToCanvas(posterDataUrl, 248); + var posterDataUrl = convertCanvasToBlackAndWhite(resizedPosterCanvas).toDataURL(); + // Compare drawing with video, and blank with video var checkrem = new Rembrandt({ - imageA: comparison, - imageB: convertCanvasToBlackAndWhite(canvas), - thresholdType: Rembrandt.THRESHOLD_PERCENT, - maxThreshold: 0.2, - maxDelta: 20, - maxOffset: 0, - // renderComposition: true, - // compositionMaskColor: new Rembrandt.Color(0.54, 0.57, 0.62) + imageA: posterDataUrl, + imageB: convertCanvasToBlackAndWhite(canvas).toDataURL(), + maxOffset: 2, + renderComposition: true, }); var blankrem = new Rembrandt({ - imageA: comparison, - imageB: blankcanv.toDataURL(), - // renderComposition: true, - // compositionMaskColor: new Rembrandt.Color(0.54, 0.57, 0.62) + imageA: posterDataUrl, + imageB: newBlankCanvas(248, 248).toDataURL(), + maxOffset: 2, }); var blank = await blankrem.compare(); var check = await checkrem.compare(); + console.image(check.compositionImage.src); // Find the drawing score relative to the complexity of the kanji - return Math.max(1 - check.percentageDifference / blank.percentageDifference, 0) * 100; + var score = (1 - check.percentageDifference / blank.percentageDifference - blank.percentageDifference); + + return Math.max(score, 0) * 100; } /** @@ -172,7 +136,7 @@ export async function fetchKanjiDetails(kanji) { const rapidAPI = atob("bjZ2SVQ5ZDU0Wm1zaEVlSlk1ZUdBSFpNQmt0cXAxV1V1Tmdqc253OWxpYXVRRVVFVXU"); const infosection = document.getElementById("infosection"); const options = { - headers: {"x-rapidapi-key": rapidAPI}, + headers: { "x-rapidapi-key": rapidAPI }, cache: "force-cache", }; @@ -204,8 +168,9 @@ export async function fetchKanjiDetails(kanji) { */ export async function fetchSetFromID(id) { // Finds a set from a given ID - var sets = (await chrome.storage.local.get("customsets")).customsets; - return sets.find(x => x.id == id); + var { customsets } = await chrome.storage.local.get("customsets"); + + return customsets.find(x => x.id == id); } /** @@ -215,17 +180,17 @@ export async function fetchSetFromID(id) { * @returns {CustomSet} Any currently enabled set */ export async function fetchAnySet() { - var sets = (await chrome.storage.local.get("customsets")).customsets; - var pass = sets.find(item => item.enabled); + var { customsets } = await chrome.storage.local.get("customsets"); + var pass = customsets.find(item => item.enabled); - // Return if found + // Return if any are already enabled if (pass) return pass; // If none are found, we enable the first set and return that one - sets[0].enabled = true; - await chrome.storage.local.set({ customsets: sets }); + customsets[0].enabled = true; + await chrome.storage.local.set({ customsets }); - return sets[0]; + return customsets[0]; } /** @@ -235,8 +200,9 @@ export async function fetchAnySet() { */ export async function fetchRandomSet() { // Fetch a random set - var sets = (await chrome.storage.local.get("customsets")).customsets; - return sets[Math.floor(Math.random() * sets.length)]; + var { customsets } = await chrome.storage.local.get("customsets"); + + return customsets[Math.floor(Math.random() * customsets.length)]; } /** @@ -247,3 +213,28 @@ export async function fetchRandomSet() { export async function fetchAllSets() { return (await chrome.storage.local.get("customsets")).customsets; } + +/** + * Prints an image to the console. Credit to @adriancooney + * https://github.com/adriancooney/console.image + * + * @param {URL} url The URL of the image to print to the console + */ +console.image = function(url) { + var img = new Image(); + + img.onload = function() { + var properties = [ + `font-size: 1px`, + `padding: 0px ${Math.floor(this.width / 2)}px`, + `line-height: ${this.height}px`, + `background: url(${url})`, + `background-size: ${this.width}px ${this.height}px`, + `color: transparent`, + ]; + + console.log("%c.", properties.join(";")); + }; + + img.src = url; +}; diff --git a/manifest.json b/manifest.json index 6e6fe9d..3a36baa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "KanjiThing", "description": "Learn kanji stroke order from the browser", - "version": "2.1.1", + "version": "2.2.0", "manifest_version": 3, "permissions": ["storage", "contextMenus"], "options_page": "settings.html",