您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Practice your current level's Radicals and Kanji with standard, english -> Kanji, and combination mode!
(function () { 'use strict'; // ==UserScript== // @name Extra Practice // @namespace https://github.com/mrpassiontea/Extra-Practice // @version 2.0.0 // @description Practice your current level's Radicals and Kanji with standard, english -> Kanji, and combination mode! // @author @mrpassiontea // @match https://www.wanikani.com/ // @match *://*.wanikani.com/dashboard // @match *://*.wanikani.com/dashboard?* // @copyright 2025, mrpassiontea // @grant none // @grant window.onurlchange // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js // @require https://unpkg.com/[email protected]/wanakana.min.js // @license MIT; http://opensource.org/licenses/MIT // @run-at document-end // ==/UserScript== const SELECTORS = { DIV_LEVEL_PROGRESS_CONTENT: "div.wk-panel__content div.level-progress-dashboard", DIV_CONTENT_WRAPPER: "div.level-progress-dashboard__content", DIV_CONTENT_TITLE: "div.level-progress-dashboard__content-title" }; const DB_VALUES = { DB_NAME: "wkof.file_cache", USER_RECORD: "Apiv2.user", SUBJECT_RECORD: "Apiv2.subjects", FILE_STORE: "files" }; const DB_ERRORS = { OPEN: "Failed to open database", USER_LEVEL: "Failed to retrieve user level", SUBJECT_DATA: "Failed to retrieve subjects data" }; const PRACTICE_MODES = { STANDARD: 'standard', ENGLISH_TO_KANJI: 'englishToKanji', COMBINED: 'combined' }; const modalTemplate = ` <div id="ep-practice-modal"> <div id="ep-practice-modal-content"> <div id="ep-practice-modal-welcome"> <h1>Hello, <span id="username"></span></h1> <h2>Please select all the Radicals that you would like to include in your practice session</h2> </div> <button id="ep-practice-modal-select-all">Select All</button> <div id="ep-practice-modal-grid"></div> <div id="ep-practice-modal-footer"> <button id="ep-practice-modal-start" disabled>Start Review (0 Selected)</button> <button id="ep-practice-modal-close">Exit</button> </div> </div> </div> `; const reviewModalTemplate = ` <div id="ep-review-modal"> <div id="ep-review-modal-wrapper"> <div id="ep-review-modal-header"> <div id="ep-review-progress"> <span id="ep-review-progress-correct">0</span> </div> <button id="ep-review-exit">End Review</button> </div> <div id="ep-review-content"> <div id="ep-review-character"></div> <div id="ep-review-input-section"> <input type="text" id="ep-review-answer" placeholder="Enter meaning..." tabindex="1" autofocus /> <button id="ep-review-submit" tabindex="2">Submit</button> </div> <div id="ep-review-result" style="display: none;"> <div id="ep-review-result-message"></div> <button id="ep-review-show-hint" style="display: none;">Show Answer</button> </div> <div id="ep-review-explanation" style="display: none;"> <h3> <span id="ep-review-meaning-label">Meaning:</span> <span id="ep-review-meaning"></span> </h3> <div class="mnemonic-container"> <span id="ep-review-mnemonic-label">Mnemonic:</span> <div id="ep-review-mnemonic"></div> </div> </div> </div> </div> </div> `; // Theme constants for consistent values across the application const theme = { colors: { radical: "#0598e4", kanji: "#eb019c", white: "#FFFFFF", black: "#000000", gray: { 100: "#F3F4F6", 200: "#E5E7EB", 300: "#D1D5DB", 400: "#9CA3AF", 600: "#4B5563", 700: "#374151", 800: "#1F2937"}, overlay: { dark: "rgba(0, 0, 0, 0.9)"}, success: "#10B981", error: "#EF4444", info: "#3B82F6" }, spacing: { xs: "0.5rem", // 8px sm: "0.75rem", // 12px md: "1rem", // 16px lg: "1.5rem", // 24px xl: "2rem"}, typography: { fontSize: { xs: "0.875rem", // 14px sm: "1rem", // 16px md: "1.25rem", // 20px lg: "1.5rem", // 24px xl: "2rem", // 32px "2xl": "6rem" // 96px (for the big character display) }, fontWeight: { normal: "400", medium: "500", bold: "700" } }, borderRadius: { sm: "3px", md: "4px", lg: "8px" }, zIndex: { modal: 99999 } }; // Common style mixins for reusable patterns const mixins = { modalBackdrop: { position: "fixed", top: "0", left: "0", width: "100%", height: "100%", zIndex: theme.zIndex.modal }}; // Component-specific styles const styles = { layout: { contentTitle: { display: "flex", justifyContent: "space-between", alignItems: "center" } }, buttons: { practice: { radical: { marginBottom: theme.spacing.md, backgroundColor: theme.colors.radical, padding: theme.spacing.sm, borderRadius: theme.borderRadius.sm, color: theme.colors.white, fontWeight: theme.typography.fontWeight.medium, cursor: "pointer" }, kanji: { marginBottom: theme.spacing.md, backgroundColor: theme.colors.kanji, padding: theme.spacing.sm, borderRadius: theme.borderRadius.sm, color: theme.colors.white, fontWeight: theme.typography.fontWeight.medium, cursor: "pointer" } } }, practiceModal: { backdrop: { ...mixins.modalBackdrop, backgroundColor: theme.colors.overlay.dark, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center" }, contentWrapper: { width: "100%", maxWidth: "800px", padding: `0 ${theme.spacing.xl}`, display: "flex", flexDirection: "column", alignItems: "center" }, welcomeText: { container: { color: theme.colors.white, textAlign: "center", fontSize: theme.typography.fontSize.sm, marginBottom: theme.spacing.md, display: "flex", flexDirection: "column", alignItems: "center", maxWidth: "750px" }, username: { fontSize: theme.typography.fontSize.xl, marginBottom: theme.spacing.md } }, grid: { display: "grid", gridTemplateColumns: "repeat(5, minmax(100px, 1fr))", gap: theme.spacing.md, padding: `${theme.spacing.md} ${theme.spacing.xl}`, maxHeight: "50vh", maxWidth: "600px", margin: "0 auto", justifyContent: "center" }, radical: { base: { background: "rgba(255, 255, 255, 0.1)", border: "2px solid rgba(255, 255, 255, 0.2)", borderRadius: theme.borderRadius.lg, padding: theme.spacing.md, cursor: "pointer", display: "flex", flexDirection: "column", alignItems: "center", transition: "all 0.2s ease" }, selected: { background: "rgba(5, 152, 228, 0.3)", border: `2px solid ${theme.colors.radical}` }, character: { fontSize: theme.typography.fontSize.xl, color: theme.colors.white }}, buttons: { start: { base: { padding: `${theme.spacing.sm} ${theme.spacing.lg}`, borderRadius: theme.borderRadius.md, border: "none", fontWeight: theme.typography.fontWeight.medium, transition: "all 0.2s ease", cursor: "pointer", color: theme.colors.white }, radical: { backgroundColor: theme.colors.radical, '&:hover': { backgroundColor: theme.colors.radical, opacity: 0.9 } }, kanji: { backgroundColor: theme.colors.kanji, '&:hover': { backgroundColor: theme.colors.kanji, opacity: 0.9 } } }, selectAll: { color: theme.colors.white, background: "transparent", border: `1px solid ${theme.colors.white}`, cursor: "pointer", fontSize: theme.typography.fontSize.xs, marginBottom: theme.spacing.md, padding: theme.spacing.sm, borderRadius: theme.borderRadius.sm, fontWeight: theme.typography.fontWeight.bold, transition: "all 0.2s ease" }, exit: { border: `1px solid ${theme.colors.white}`, backgroundColor: "rgba(255, 255, 255, 0.9)", padding: `${theme.spacing.sm} ${theme.spacing.md}`, color: theme.colors.black, fontWeight: theme.typography.fontWeight.medium, borderRadius: theme.borderRadius.sm, cursor: "pointer", transition: "all 0.2s ease" } }, footer: { padding: `${theme.spacing.md} ${theme.spacing.xl}`, display: "flex", justifyContent: "center", width: "100%", maxWidth: "600px", gap: theme.spacing.md }, modeSelector: { container: { display: "flex", flexDirection: "column", alignItems: "center", marginBottom: theme.spacing.xl, width: "100%", maxWidth: "600px" }, label: { color: theme.colors.white, fontSize: theme.typography.fontSize.md, marginBottom: theme.spacing.md }, options: { display: "flex", gap: theme.spacing.md, justifyContent: "center", width: "100%" }, option: { base: { padding: `${theme.spacing.sm} ${theme.spacing.md}`, borderRadius: theme.borderRadius.md, border: `2px solid ${theme.colors.gray[400]}`, backgroundColor: "transparent", color: theme.colors.white, cursor: "pointer", transition: "all 0.2s ease", fontSize: theme.typography.fontSize.sm, fontWeight: theme.typography.fontWeight.medium, '&:hover': { borderColor: theme.colors.kanji, backgroundColor: "rgba(235, 1, 156, 0.1)" } }, selected: { borderColor: theme.colors.kanji, backgroundColor: "rgba(235, 1, 156, 0.2)" } } }}, reviewModal: { container: { backgroundColor: theme.colors.white, borderRadius: theme.borderRadius.lg, boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", maxWidth: "600px", width: "100%", display: "flex", flexDirection: "column" }, header: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: theme.spacing.lg, borderBottom: `1px solid ${theme.colors.gray[200]}`, gap: theme.spacing.md }, progress: { fontWeight: theme.typography.fontWeight.bold, fontSize: theme.typography.fontSize.md, color: theme.colors.gray[800] }, content: { padding: theme.spacing.xl, display: "flex", flexDirection: "column", width: "100%", gap: theme.spacing.xl, }, character: { fontSize: theme.typography.fontSize["2xl"], color: theme.colors.gray[800], marginBottom: theme.spacing.xl, textAlign: "center" }, inputSection: { width: "100%", display: "flex", gap: theme.spacing.md, marginBottom: theme.spacing.xl }, input: { flex: "1", padding: theme.spacing.sm, fontSize: theme.typography.fontSize.sm, borderRadius: theme.borderRadius.md, border: `1px solid ${theme.colors.gray[300]}` }, buttons: { submit: { backgroundColor: theme.colors.info, color: theme.colors.white, padding: `${theme.spacing.sm} ${theme.spacing.lg}`, borderRadius: theme.borderRadius.md, border: "none", fontWeight: theme.typography.fontWeight.medium, cursor: "pointer", transition: "background-color 0.2s ease", "&:hover": { backgroundColor: "#2563EB" } }, exit: { backgroundColor: "transparent", color: theme.colors.kanji, border: `1px solid ${theme.colors.kanji}`, borderRadius: theme.borderRadius.md, padding: `${theme.spacing.sm} ${theme.spacing.lg}`, fontWeight: theme.typography.fontWeight.medium, cursor: "pointer", transition: "background-color 0.2s ease", "&:hover": { backgroundColor: theme.colors.gray[100] } }, hint: { backgroundColor: "transparent", color: theme.colors.info, border: `1px solid ${theme.colors.info}`, borderRadius: theme.borderRadius.md, padding: `${theme.spacing.sm} ${theme.spacing.lg}`, cursor: "pointer", transition: "background-color 0.2s ease", "&:hover": { backgroundColor: theme.colors.gray[100] } } }, results: { message: { fontSize: theme.typography.fontSize.lg, fontWeight: theme.typography.fontWeight.bold, marginBottom: theme.spacing.md, color: theme.colors.info, textAlign: "center", "&.correct": { color: theme.colors.success }, "&.incorrect": { color: theme.colors.error } } }, explanation: { lineHeight: "1.6", color: theme.colors.gray[600], fontSize: theme.typography.fontSize.md, meaningLabel: { display: "inline-block", fontWeight: theme.typography.fontWeight.normal, fontSize: theme.typography.fontSize.md, color: theme.colors.gray[800], marginRight: theme.spacing.xs }, meaningText: { display: "inline-block", fontWeight: theme.typography.fontWeight.bold, fontSize: theme.typography.fontSize.md, color: theme.colors.radical[800], textDecoration: "none" }, mnemonicContainer: { marginTop: theme.spacing.md, textAlign: "left", lineHeight: "1.6" }, mnemonicLabel: { display: "block", fontWeight: theme.typography.fontWeight.bold, fontSize: theme.typography.fontSize.md, color: theme.colors.gray[800], marginBottom: theme.spacing.xs }, mnemonic: { color: theme.colors.gray[600], fontSize: theme.typography.fontSize.md }, mnemonicHighlight: { backgroundColor: theme.colors.gray[200], padding: `0 ${theme.spacing.xs}`, borderRadius: theme.borderRadius.sm, color: theme.colors.gray[800] } }, kanjiOption: { base: { padding: theme.spacing.lg, borderRadius: theme.borderRadius.md, border: `2px solid ${theme.colors.gray[300]}`, backgroundColor: theme.colors.white, display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s ease", '&:hover': { borderColor: theme.colors.kanji, backgroundColor: "rgba(235, 1, 156, 0.1)" } }, selected: { borderColor: theme.colors.kanji, backgroundColor: "rgba(235, 1, 156, 0.2)" } } } }; const PRACTICE_TYPES = { RADICAL: "radical", KANJI: "kanji" }; const MODAL_STATES$1 = { READY: "ready" }; const EVENTS$1 = { CLOSE: "close", START_REVIEW: "startReview" }; class BaseReviewSession { constructor(selectedItems) { if (new.target === BaseReviewSession) { throw new Error("BaseReviewSession is an abstract class and cannot be instantiated directly."); } this.originalItems = selectedItems; this.currentItem = null; } shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } nextItem() { throw new Error("nextItem() must be implemented by derived classes"); } checkAnswer(userAnswer) { throw new Error("checkAnswer() must be implemented by derived classes"); } isComplete() { throw new Error("isComplete() must be implemented by derived classes"); } getProgress() { throw new Error("getProgress() must be implemented by derived classes"); } } class KanjiReviewSession extends BaseReviewSession { constructor(config) { super(config.items); this.mode = config.mode || PRACTICE_MODES.STANDARD; this.allUnlockedKanji = config.allUnlockedKanji || []; this.allCards = []; this.remainingItems = []; // Progress tracking this.correctMeanings = new Set(); this.correctReadings = new Set(); this.correctRecognition = new Set(); // Initialize cards based on mode this.initializeCards(); } initializeCards() { switch (this.mode) { case PRACTICE_MODES.STANDARD: this.initializeStandardCards(); break; case PRACTICE_MODES.ENGLISH_TO_KANJI: this.initializeRecognitionCards(); break; case PRACTICE_MODES.COMBINED: this.initializeStandardCards(); this.initializeRecognitionCards(); break; } // Shuffle all cards together this.remainingItems = this.shuffleArray([...this.allCards]); } initializeStandardCards() { this.originalItems.forEach(kanji => { // Add meaning card this.allCards.push({ ...kanji, type: "meaning", questionType: "What is the meaning of this kanji?" }); // Add reading card this.allCards.push({ ...kanji, type: "reading", questionType: "What is the reading of this kanji?" }); }); } initializeRecognitionCards() { this.originalItems.forEach(kanji => { const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning; // Create recognition card this.allCards.push({ ...kanji, type: "recognition", questionType: "Select the kanji that means", meaningToMatch: primaryMeaning, options: this.generateKanjiOptions(kanji) }); }); } generateKanjiOptions(correctKanji) { const numberOfOptions = 4; const options = [correctKanji]; // Create a pool of incorrect options from the selected kanji const availableOptions = this.originalItems.filter(k => k.id !== correctKanji.id); // Randomly select additional options from the available pool while (options.length < numberOfOptions && availableOptions.length > 0) { const randomIndex = Math.floor(Math.random() * availableOptions.length); const selectedOption = availableOptions[randomIndex]; options.push(selectedOption); availableOptions.splice(randomIndex, 1); } // If we still need more options (rare case when very few kanji are selected) // fill remaining slots with kanji from allUnlockedKanji if (options.length < numberOfOptions) { const additionalOptions = this.allUnlockedKanji.filter(k => !options.some(selected => selected.id === k.id) && !this.originalItems.some(selected => selected.id === k.id) ); while (options.length < numberOfOptions && additionalOptions.length > 0) { const randomIndex = Math.floor(Math.random() * additionalOptions.length); const selectedOption = additionalOptions[randomIndex]; options.push(selectedOption); additionalOptions.splice(randomIndex, 1); } } return this.shuffleArray(options); } nextItem() { if (this.remainingItems.length === 0) { // Get items that haven't been answered correctly const remainingUnlearned = []; this.originalItems.forEach(kanji => { switch (this.mode) { case PRACTICE_MODES.STANDARD: if (!this.correctMeanings.has(kanji.id)) { remainingUnlearned.push({ ...kanji, type: "meaning", questionType: "What is the meaning of this kanji?" }); } if (!this.correctReadings.has(kanji.id)) { remainingUnlearned.push({ ...kanji, type: "reading", questionType: "What is the reading of this kanji?" }); } break; case PRACTICE_MODES.ENGLISH_TO_KANJI: if (!this.correctRecognition.has(kanji.id)) { const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning; remainingUnlearned.push({ ...kanji, type: "recognition", questionType: "Select the kanji that means", meaningToMatch: primaryMeaning, options: this.generateKanjiOptions(kanji) }); } break; case PRACTICE_MODES.COMBINED: if (!this.correctMeanings.has(kanji.id)) { remainingUnlearned.push({ ...kanji, type: "meaning", questionType: "What is the meaning of this kanji?" }); } if (!this.correctReadings.has(kanji.id)) { remainingUnlearned.push({ ...kanji, type: "reading", questionType: "What is the reading of this kanji?" }); } if (!this.correctRecognition.has(kanji.id)) { const primaryMeaning = kanji.meanings.find(m => m.primary)?.meaning; remainingUnlearned.push({ ...kanji, type: "recognition", questionType: "Select the kanji that means", meaningToMatch: primaryMeaning, options: this.generateKanjiOptions(kanji) }); } break; } }); // Shuffle the remaining items if (remainingUnlearned.length > 0) { this.remainingItems = this.shuffleArray(remainingUnlearned); } } this.currentItem = this.remainingItems.shift(); return this.currentItem; } checkAnswer(userAnswer) { if (!this.currentItem) return false; let isCorrect = false; switch (this.currentItem.type) { case "meaning": isCorrect = this.checkMeaningAnswer(userAnswer); if (isCorrect) this.correctMeanings.add(this.currentItem.id); break; case "reading": isCorrect = this.checkReadingAnswer(userAnswer); if (isCorrect) this.correctReadings.add(this.currentItem.id); break; case "recognition": isCorrect = parseInt(userAnswer) === this.currentItem.id; if (isCorrect) this.correctRecognition.add(this.currentItem.id); break; } return isCorrect; } checkMeaningAnswer(userAnswer) { const normalizedUserAnswer = userAnswer.toLowerCase().trim(); // Check primary meanings const isPrimaryCorrect = this.currentItem.meanings.some(m => m.meaning.toLowerCase() === normalizedUserAnswer ); if (isPrimaryCorrect) return true; // Check auxiliary meanings return this.currentItem.auxiliaryMeanings.some(m => m.meaning.toLowerCase() === normalizedUserAnswer ); } checkReadingAnswer(userAnswer) { const userReading = userAnswer.trim(); return this.currentItem.readings.some(r => r.reading === userReading); } isComplete() { const progress = this.getProgress(); return progress.current === progress.total; } getProgress() { const totalKanji = this.originalItems.length; let total, current; switch (this.mode) { case PRACTICE_MODES.STANDARD: total = totalKanji * 2; // One point each for meaning and reading current = this.correctMeanings.size + this.correctReadings.size; return { total, current, meaningProgress: this.correctMeanings.size, readingProgress: this.correctReadings.size }; case PRACTICE_MODES.ENGLISH_TO_KANJI: total = totalKanji; // One point for each recognition test current = this.correctRecognition.size; return { total, current, recognitionProgress: this.correctRecognition.size }; case PRACTICE_MODES.COMBINED: total = totalKanji * 3; // One point each for meaning, reading, and recognition current = this.correctMeanings.size + this.correctReadings.size + this.correctRecognition.size; return { total, current, meaningProgress: this.correctMeanings.size, readingProgress: this.correctReadings.size, recognitionProgress: this.correctRecognition.size }; default: return { total: 0, current: 0 }; } } } class RadicalReviewSession extends BaseReviewSession { constructor(config) { super(config.items); this.remainingItems = this.shuffleArray([...config.items]); this.correctAnswers = new Set(); } nextItem() { if (this.remainingItems.length === 0) { const remainingUnlearned = this.originalItems.filter(item => !this.correctAnswers.has(item.id)); if (remainingUnlearned.length === 1) { this.remainingItems = remainingUnlearned; } else { this.remainingItems = this.shuffleArray( remainingUnlearned.filter(item => !this.currentItem || item.id !== this.currentItem.id) ); } } this.currentItem = this.remainingItems.shift(); return this.currentItem; } checkAnswer(userAnswer) { const isCorrect = this.currentItem.meaning.toLowerCase() === userAnswer.toLowerCase(); if (isCorrect) { this.correctAnswers.add(this.currentItem.id); } return isCorrect; } isComplete() { return this.correctAnswers.size === this.originalItems.length; } getProgress() { const totalRadicals = this.originalItems.length; let current = this.correctAnswers.size; return { current, total: totalRadicals, remaining: totalRadicals - current, percentComplete: Math.round((current / totalRadicals) * 100) }; } } function disableScroll() { const scrollPosition = window.scrollY || document.documentElement.scrollTop; $("html, body").css({ overflow: "hidden", height: "100%", position: "fixed", top: `-${scrollPosition}px`, width: "100%", }); } function enableScroll() { const scrollPosition = parseInt($("html").css("top")) * -1; $("html, body").css({ overflow: "auto", height: "auto", position: "static", top: "auto", width: "auto", }); window.scrollTo(0, scrollPosition); } // Cache for SVG content to avoid repeated fetches const svgCache = new Map(); async function loadSvgContent(url) { if (svgCache.has(url)) { return svgCache.get(url); } const response = await fetch(url); const svgContent = await response.text(); svgCache.set(url, svgContent); return svgContent; } class RadicalGrid { constructor(radicals, onSelectionChange) { this.radicals = radicals; this.selectedRadicals = new Set(); this.onSelectionChange = onSelectionChange; this.$container = null; } updateRadicalSelection($element, radical, isSelected) { $element.css( isSelected ? { ...styles.practiceModal.radical.base, ...styles.practiceModal.radical.selected } : styles.practiceModal.radical.base ); if (isSelected) { this.selectedRadicals.add(radical.id); } else { this.selectedRadicals.delete(radical.id); } this.onSelectionChange(this.selectedRadicals); } toggleAllRadicals(shouldSelect) { if (shouldSelect) { this.radicals.forEach(radical => this.selectedRadicals.add(radical.id)); } else { this.selectedRadicals.clear(); } this.$container.find(".radical-selection-item").each((_, element) => { const $element = $(element); const radicalId = parseInt($element.data("radical-id")); this.updateRadicalSelection( $element, this.radicals.find(r => r.id === radicalId), shouldSelect ); }); this.onSelectionChange(this.selectedRadicals); } getSelectedRadicals() { return Array.from(this.selectedRadicals).map(id => this.radicals.find(radical => radical.id === id) ); } async createRadicalElement(radical) { const $element = $("<div>") .addClass("radical-selection-item") .css(styles.practiceModal.radical.base) .data("radical-id", radical.id) .append( $("<div>") .addClass("radical-character") .css(styles.practiceModal.radical.character) .text(radical.character || "") ) .on("click", () => { const isCurrentlySelected = this.selectedRadicals.has(radical.id); this.updateRadicalSelection($element, radical, !isCurrentlySelected); }); if (!radical.character && radical.svg) { try { const svgContent = await loadSvgContent(radical.svg); $element.find(".radical-character").html(svgContent); const svg = $element.find("svg")[0]; if (svg) { svg.setAttribute("width", "100%"); svg.setAttribute("height", "100%"); } } catch (error) { console.error("Error loading SVG:", error); $element.find(".radical-character").text(radical.meaning); } } return $element; } async render() { this.$container = $("<div>") .css(styles.practiceModal.grid); // Create and append all radical elements const radicalElements = await Promise.all( this.radicals.map(radical => this.createRadicalElement(radical)) ); radicalElements.forEach($element => this.$container.append($element)); return this.$container; } } class RadicalSelectionModal { constructor(radicals) { this.radicals = radicals; this.state = MODAL_STATES$1.READY; this.totalRadicals = radicals.length; this.$modal = null; this.radicalGrid = null; this.callbacks = new Map(); } on(event, callback) { this.callbacks.set(event, callback); return this; } emit(event, data) { const callback = this.callbacks.get(event); if (callback) callback(data); } updateSelectAllButton(selectedCount) { const selectAllButton = $("#ep-practice-modal-select-all"); const isAllSelected = selectedCount === this.totalRadicals; selectAllButton .text(isAllSelected ? "Deselect All" : "Select All") .css({ color: isAllSelected ? theme.colors.error : theme.colors.white, borderColor: isAllSelected ? theme.colors.error : theme.colors.white }); } updateStartButton(selectedCount) { const startButton = $("#ep-practice-modal-start"); if (selectedCount > 0) { startButton .prop("disabled", false) .text(`Start Review (${selectedCount} Selected)`) .css({ ...styles.practiceModal.buttons.start.base, ...styles.practiceModal.buttons.start.radical }); } else { startButton .prop("disabled", true) .text("Start Review (0 Selected)") .css({ ...styles.practiceModal.buttons.start.base, ...styles.practiceModal.buttons.start.radical, ...styles.practiceModal.buttons.start.disabled }); } } handleSelectionChange(selectedRadicals) { const selectedCount = selectedRadicals.size; this.updateSelectAllButton(selectedCount); this.updateStartButton(selectedCount); } async render() { this.$modal = $(modalTemplate).appendTo("body"); $("#username").text($("p.user-summary__username:first").text()); this.$modal.css(styles.practiceModal.backdrop); $("#ep-practice-modal-welcome").css(styles.practiceModal.welcomeText.container); $("#ep-practice-modal-welcome h1").css(styles.practiceModal.welcomeText.username); $("#ep-practice-modal-footer").css(styles.practiceModal.footer); $("#ep-practice-modal-start").css({ ...styles.practiceModal.buttons.start.base, ...styles.practiceModal.buttons.start.radical, ...styles.practiceModal.buttons.start.disabled }); $("#ep-practice-modal-select-all").css(styles.practiceModal.buttons.selectAll); $("#ep-practice-modal-content").css(styles.practiceModal.contentWrapper); $("#ep-practice-modal-close").css(styles.practiceModal.buttons.exit); this.radicalGrid = new RadicalGrid( this.radicals, this.handleSelectionChange.bind(this) ); const $grid = await this.radicalGrid.render(); $("#ep-practice-modal-grid").replaceWith($grid); this.updateStartButton(0); $("#ep-practice-modal-select-all").on("click", () => { const isSelectingAll = $("#ep-practice-modal-select-all").text() === "Select All"; this.radicalGrid.toggleAllRadicals(isSelectingAll); }); $("#ep-practice-modal-close").on("click", () => { this.emit(EVENTS$1.CLOSE); }); $("#ep-practice-modal-start").on("click", () => { const selectedRadicals = this.radicalGrid.getSelectedRadicals(); if (selectedRadicals.length > 0) { this.emit(EVENTS$1.START_REVIEW, selectedRadicals); } }); return this.$modal; } remove() { if (this.$modal) { this.$modal.remove(); this.$modal = null; } } } const REVIEW_STATES = { ANSWERING: "answering", REVIEWING: "reviewing"}; const REVIEW_EVENTS = { CLOSE: "close", NEXT_ITEM: "nextItem", COMPLETE: "complete", STUDY_AGAIN: "studyAgain" }; class ReviewCard { constructor(item, state = REVIEW_STATES.ANSWERING) { this.item = item; this.state = state; this.$container = null; this.isKanji = !!this.item.readings; this.selectedOption = null; this.handleKanjiSelection = this.handleKanjiSelection.bind(this); } handleKanjiSelection(event, option) { const $selectedElement = $(event.currentTarget); this.$container.find('.kanji-option').css(styles.reviewModal.kanjiOption.base); $selectedElement.css({ ...styles.reviewModal.kanjiOption.base, ...styles.reviewModal.kanjiOption.selected }); this.selectedOption = option.id; const $submitButton = this.$container.find('#ep-review-submit'); $submitButton .prop('disabled', false) .css({ ...styles.reviewModal.buttons.submit, opacity: 1, cursor: "pointer" }); } getQuestionText() { if (this.item.type === "recognition") { return ["Select the kanji that means ", this.createEmphasisSpan(this.item.meaningToMatch)]; } if (!this.isKanji) { return ["What is the meaning of this ", this.createEmphasisSpan("radical"), "?"]; } if (this.item.type === "reading") { const readingType = this.item.readings.find(r => r.primary)?.type; const readingText = readingType === "onyomi" ? "on'yomi" : "kun'yomi"; return ["What is the ", this.createEmphasisSpan(readingText), " reading for this kanji?"]; } return ["What is the ", this.createEmphasisSpan("meaning"), " of this kanji?"]; } createEmphasisSpan(text) { return $("<span>") .text(text) .css({ fontWeight: theme.typography.fontWeight.bold, color: this.isKanji ? theme.colors.kanji : theme.colors.radical, padding: `${theme.spacing.xs}`, borderRadius: theme.borderRadius.sm, backgroundColor: this.isKanji ? "rgba(235, 1, 156, 0.1)" : "rgba(5, 152, 228, 0.1)" }); } createKanjiOption(option) { const $option = $("<div>") .addClass("kanji-option") .css(styles.reviewModal.kanjiOption.base) .data("kanji-id", option.id) .append( $("<div>") .addClass("kanji-character") .css({ fontSize: theme.typography.fontSize["2xl"], color: theme.colors.gray[800], textAlign: "center" }) .text(option.character) ); $option.on("click", (event) => this.handleKanjiSelection(event, option)); return $option; } async renderCharacter() { const $character = $("<div>") .addClass("ep-review-character") .css(styles.reviewModal.character); if (this.item.character) { $character.text(this.item.character); } else if (this.item.svg) { try { const svgContent = await loadSvgContent(this.item.svg); $character.html(svgContent); const svg = $character.find("svg")[0]; if (svg) { svg.setAttribute("width", "100%"); svg.setAttribute("height", "100%"); } } catch (error) { console.error("Error loading SVG:", error); $character.text(this.item.meaning); } } return $character; } async renderAnsweringState() { const $content = $("<div>").addClass("ep-review-content"); if (this.item.type === "recognition") { return this.renderRecognitionCard($content); } else { const $character = await this.renderCharacter(); const $question = $("<div>") .addClass("ep-review-question") .css({ fontSize: theme.typography.fontSize.lg, marginBottom: theme.spacing.lg, color: theme.colors.gray[700] }); const questionContent = this.getQuestionText(); questionContent.forEach(content => { if (content instanceof jQuery) { $question.append(content); } else { $question.append(document.createTextNode(content)); } }); const $inputSection = $("<div>") .addClass("ep-review-input-section") .css(styles.reviewModal.inputSection) .append( $("<input>") .attr({ type: "text", id: "ep-review-answer", placeholder: this.item.type === "reading" ? "Enter reading..." : "Enter meaning...", tabindex: "1", autofocus: true }) .css(styles.reviewModal.input), $("<button>") .attr("id", "ep-review-submit") .text("Submit") .attr("tabindex", "2") .css(styles.reviewModal.buttons.submit) ); $content.append($character); $content.append($question); $content.append($inputSection); return $content; } } async renderStandardAnsweringCard($content) { const $character = await this.renderCharacter(); const $question = $("<div>") .addClass("ep-review-question") .css({ fontSize: theme.typography.fontSize.lg, marginBottom: theme.spacing.lg, color: theme.colors.gray[700] }); const questionContent = this.getQuestionText(); questionContent.forEach(content => { if (content instanceof jQuery) { $question.append(content); } else { $question.append(document.createTextNode(content)); } }); const $inputSection = $("<div>") .addClass("ep-review-input-section") .css(styles.reviewModal.inputSection) .append( $("<input>") .attr({ type: "text", id: "ep-review-answer", placeholder: this.item.type === "reading" ? "Enter reading..." : "Enter meaning...", tabindex: "1", autofocus: true }) .css(styles.reviewModal.input), $("<button>") .attr("id", "ep-review-submit") .text("Submit") .attr("tabindex", "2") .css(styles.reviewModal.buttons.submit) ); return $content.append($character, $question, $inputSection); } async renderRecognitionCard($content) { const $questionContainer = $("<div>") .css({ textAlign: "center", marginBottom: theme.spacing.xl }); const $question = $("<div>") .addClass("ep-review-question") .css({ fontSize: theme.typography.fontSize.lg, color: theme.colors.gray[700], marginBottom: theme.spacing.md }); const questionContent = this.getQuestionText(); questionContent.forEach(content => { if (content instanceof jQuery) { $question.append(content); } else { $question.append(document.createTextNode(content)); } }); $questionContainer.append($question); const $optionsGrid = $("<div>") .css({ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: theme.spacing.lg, padding: theme.spacing.xl, maxWidth: "500px", margin: "0 auto" }); this.item.options.forEach(option => { $optionsGrid.append(this.createKanjiOption(option)); }); const $submitButton = $("<button>") .attr({ id: "ep-review-submit", disabled: true }) .text("Submit") .css({ ...styles.reviewModal.buttons.submit, opacity: 0.5, cursor: "not-allowed" }); const $submitButtonContainer = $("<div>") .css({ textAlign: "center", marginTop: theme.spacing.xl }) .append($submitButton); return $content.append($questionContainer, $optionsGrid, $submitButtonContainer); } processMnemonic(mnemonic) { if (!mnemonic) return ""; if (!this.isKanji) { return mnemonic.replace(/<radical>(.*?)<\/radical>/g, (_, content) => `<span style="background-color: ${theme.colors.radical}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>` ); } return mnemonic .replace(/<radical>(.*?)<\/radical>/g, (_, content) => `<span style="background-color: ${theme.colors.radical}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>` ) .replace(/<kanji>(.*?)<\/kanji>/g, (_, content) => `<span style="background-color: ${theme.colors.kanji}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.white}">${content}</span>` ) .replace(/<reading>(.*?)<\/reading>/g, (_, content) => `<span style="background-color: ${theme.colors.gray[200]}; padding: 0 ${theme.spacing.xs}; border-radius: ${theme.borderRadius.sm}; color: ${theme.colors.gray[800]}">${content}</span>` ); } async renderReviewingState() { const $content = $("<div>").addClass("ep-review-content"); const $character = await this.renderCharacter(); const $explanation = $("<div>") .addClass("ep-review-explanation") .css(styles.reviewModal.explanation); const primaryReading = this.item.readings?.find(r => r.primary); const primaryMeaning = this.item.meanings?.find(m => m.primary); const $continueButton = $("<button>") .attr("id", "ep-review-continue") .text("Continue Review") .css({ ...styles.reviewModal.buttons.submit, minWidth: "120px", display: "block", margin: "30px auto 0" }); const $buttonContainer = $("<div>") .addClass("ep-review-buttons") .css({ display: "flex", gap: theme.spacing.md, justifyContent: "center", marginTop: theme.spacing.xl }) .append($continueButton); // Handle non-kanji (radical) review state if (!this.isKanji) { $content.append( $character, $explanation.append( $("<h3>").append( $("<span>") .text("Meaning: ") .css(styles.reviewModal.explanation.meaningLabel), $("<a>") .attr({ href: this.item.documentationUrl, target: "_blank", title: `Click to learn more about: ${this.item.meaning}` }) .text(this.item.meaning) .css(styles.reviewModal.explanation.meaningText) ), $("<div>") .addClass("ep-mnemonic-container") .css(styles.reviewModal.explanation.mnemonicContainer) .append( $("<span>") .text("Mnemonic:") .css(styles.reviewModal.explanation.mnemonicLabel), $("<div>") .addClass("ep-review-mnemonic") .html(this.processMnemonic(this.item.meaningMnemonic)) .css(styles.reviewModal.explanation.mnemonic) ) ) ); $content.append($buttonContainer); return $content; } // Handle kanji review states based on question type switch (this.item.type) { case "recognition": $explanation.append( this.createExplanationSection( "Meaning", this.item.meaningToMatch, this.item.meaningMnemonic, true ) ); if (primaryReading) { const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi"; $explanation.append( this.createExplanationSection( "Reading", `${readingType}: ${primaryReading.reading}`, this.item.readingMnemonic, false ) ); } break; case "reading": if (primaryReading) { const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi"; $explanation.append( this.createExplanationSection( "Reading", `${readingType}: ${primaryReading.reading}`, this.item.readingMnemonic, true ) ); } if (primaryMeaning) { $explanation.append( this.createExplanationSection( "Meaning", primaryMeaning.meaning, this.item.meaningMnemonic, false ) ); } break; case "meaning": if (primaryMeaning) { $explanation.append( this.createExplanationSection( "Meaning", primaryMeaning.meaning, this.item.meaningMnemonic, true ) ); } if (primaryReading) { const readingType = primaryReading.type === "onyomi" ? "On'yomi" : "Kun'yomi"; $explanation.append( this.createExplanationSection( "Reading", `${readingType}: ${primaryReading.reading}`, this.item.readingMnemonic, false ) ); } break; } $content.append($character, $explanation); $content.append($buttonContainer); return $content; } createExplanationSection(title, answer, mnemonic, isExpanded) { const $section = $("<div>") .addClass("explanation-section") .css({ marginBottom: theme.spacing.md, width: "100%", display: "block" }); const $header = $("<div>") .css({ display: "block", padding: `${theme.spacing.sm} 0`, width: "100%", borderBottom: `1px solid ${theme.colors.gray[200]}`, }); const $headerContent = $("<div>") .css({ display: "flex", alignItems: "center", cursor: "pointer", width: "100%" }).append( $("<span>") .text(isExpanded ? "▼" : "▶") .css({ color: theme.colors.gray[600], marginRight: theme.spacing.sm, fontSize: theme.typography.fontSize.md, flexShink: 0 }), $("<h3>") .text(title) .css({ margin: 0, color: theme.colors.gray[800], fontWeight: theme.typography.fontWeight.medium, fontSize: theme.typography.fontSize.md, flex: 1 }) ); $header.append($headerContent); const $content = $("<div>") .css({ display: isExpanded ? "block" : "none", paddingLeft: theme.spacing.xl, paddingTop: theme.spacing.md, paddingBottom: theme.spacing.md }); if (title.toLowerCase() === "reading") { // Extract reading type and format display const readingType = this.item.readings.find(r => r.primary)?.type; const formattedType = readingType === "onyomi" ? "On'yomi" : "Kun'yomi"; $content.append( $("<div>") .css({ fontSize: theme.typography.fontSize.lg, color: theme.colors.gray[800], marginBottom: theme.spacing.md }) .append( $("<span>") .text(`${formattedType}: `) .css({ color: theme.colors.gray[600], fontSize: theme.typography.fontSize.md }), $("<span>") .text(this.item.readings.find(r => r.primary)?.reading || "") ) ); if (mnemonic) { $content.append( $("<div>") .addClass("ep-mnemonic-container") .css(styles.reviewModal.explanation.mnemonicContainer) .append( $("<span>") .text("Mnemonic:") .css(styles.reviewModal.explanation.mnemonicLabel), $("<div>") .addClass("ep-review-mnemonic") .html(this.processMnemonic(mnemonic)) .css(styles.reviewModal.explanation.mnemonic) ) ); } } else { const meaningText = this.item.type === "recognition" ? this.item.meaningToMatch : this.item.meanings.find(m => m.primary)?.meaning; $content.append( $("<div>") .css({ fontSize: theme.typography.fontSize.lg, color: theme.colors.gray[800], marginBottom: theme.spacing.md }) .text(meaningText) ); if (mnemonic) { $content.append( $("<div>") .addClass("ep-mnemonic-container") .css(styles.reviewModal.explanation.mnemonicContainer) .append( $("<span>") .text("Mnemonic:") .css(styles.reviewModal.explanation.mnemonicLabel), $("<div>") .addClass("ep-review-mnemonic") .html(this.processMnemonic(mnemonic)) .css(styles.reviewModal.explanation.mnemonic) ) ); } } $header.on("click", function() { const $content = $(this).siblings("div"); const isVisible = $content.is(":visible"); $content.slideToggle(200); const $arrow = $(this).find("span").first(); $arrow.text(isVisible ? "▶" : "▼"); }); return $section.append($header, $content); } async render() { this.$container = $("<div>") .addClass("ep-review-card") .css({ padding: theme.spacing.xl, display: "flex", flexDirection: "column", width: "100%", gap: theme.spacing.xl }); const $characterContainer = $("<div>") .css({ textAlign: "center", width: "100%" }); const $contentContainer = $("<div>") .css({ width: "100%", textAlign: "left" }); const content = await (this.state === REVIEW_STATES.ANSWERING ? this.renderAnsweringState() : this.renderReviewingState()); if (this.state === REVIEW_STATES.ANSWERING) { const $character = content.find(".ep-review-character").detach(); $characterContainer.append($character); $contentContainer.append(content); } else { const $character = content.find(".ep-review-character").detach(); $characterContainer.append($character); $contentContainer.append(content.find(".ep-review-explanation")); } this.$container.append($characterContainer, $contentContainer); return this.$container; } async updateState(newState) { if (this.state === newState) return; this.state = newState; const content = this.state === REVIEW_STATES.ANSWERING ? await this.renderAnsweringState() : await this.renderReviewingState(); this.$container.empty().append(content); } getAnswer() { if (this.item.type === "recognition") { return this.selectedOption?.toString() || ""; } return $("#ep-review-answer").val()?.trim() || ""; } remove() { if (this.$container) { this.$container.remove(); this.$container = null; } } } class ReviewSessionModal { constructor(reviewSession) { this.reviewSession = reviewSession; this.state = REVIEW_STATES.ANSWERING; this.$modal = null; this.currentCard = null; this.callbacks = new Map(); this.isKanjiSession = !!this.reviewSession.correctMeanings; // Session configuration for Play Again this.sessionConfig = { mode: this.reviewSession.mode, items: this.reviewSession.originalItems, }; if (this.sessionConfig.mode !== "radical") { this.sessionConfig.allUnlockedKanji = this.reviewSession.allUnlockedKanji; } this.handlePlayAgain = this.handlePlayAgain.bind(this); this.handleAnswer = this.handleAnswer.bind(this); this.handleNextItem = this.handleNextItem.bind(this); this.showHint = this.showHint.bind(this); this.setupInput = this.setupInput.bind(this); this.showCurrentItem = this.showCurrentItem.bind(this); this.updateProgress = this.updateProgress.bind(this); this.showReviewInterface = this.showReviewInterface.bind(this); this.hideReviewInterface = this.hideReviewInterface.bind(this); this.showInputInterface = this.showInputInterface.bind(this); this.hideInputInterface = this.hideInputInterface.bind(this); this.showCompletionScreen = this.showCompletionScreen.bind(this); } // Setup Hiragana Keyboard setupInput() { const input = document.querySelector("#ep-review-answer"); if (!input) return; const currentItem = this.reviewSession.currentItem; if (!currentItem) return; if (this.isKanjiSession && currentItem.type === "reading") { wanakana.bind(input, { IMEMode: "toHiragana", useObsoleteKana: false, passRomaji: false, upcaseKatakana: false, convertLongVowelMark: true }); } } on(event, callback) { this.callbacks.set(event, callback); return this; } emit(event, data) { const callback = this.callbacks.get(event); if (callback) callback(data); } handlePlayAgain() { const newSession = this.isKanjiSession ? new KanjiReviewSession({ items: this.sessionConfig.items, mode: this.sessionConfig.mode, allUnlockedKanji: this.sessionConfig.allUnlockedKanji }) : new RadicalReviewSession({ items: this.sessionConfig.items, mode: "radical", }); // Initialize new session newSession.nextItem(); // Clean up current modal this.remove(); const newModal = new ReviewSessionModal(newSession); newModal .on(REVIEW_EVENTS.CLOSE, () => { enableScroll(); newModal.remove(); }) .on(REVIEW_EVENTS.STUDY_AGAIN, () => { newModal.remove(); enableScroll(); if (this.isKanjiSession) { handleKanjiPractice(); } else { handleRadicalPractice(); } }); return newModal.render(); } updateProgress() { const progress = this.reviewSession.getProgress(); const mode = this.reviewSession.mode; let progressText; switch (mode) { case PRACTICE_MODES.ENGLISH_TO_KANJI: progressText = `${progress.recognitionProgress}/${progress.total} Correct`; break; case PRACTICE_MODES.COMBINED: progressText = `Meanings: ${progress.meaningProgress}/${progress.total/3} | ` + `Readings: ${progress.readingProgress}/${progress.total/3} | ` + `Recognition: ${progress.recognitionProgress}/${progress.total/3}`; break; case PRACTICE_MODES.STANDARD: progressText = `Meanings: ${progress.meaningProgress}/${progress.total/2} | ` + `Readings: ${progress.readingProgress}/${progress.total/2}`; break; default: // RADICAL progressText = `${progress.current}/${progress.total/1} Correct`; } $("#ep-review-progress-correct").html(progressText); if (mode === PRACTICE_MODES.COMBINED) { $("#ep-review-progress-correct").css({ fontSize: theme.typography.fontSize.xs }); } } showReviewInterface() { $("#ep-review-result").show(); $("#ep-review-result-message").show(); $("#ep-review-explanation").show(); $(".ep-review-buttons").hide(); } hideReviewInterface() { $("#ep-review-result").hide(); $("#ep-review-result-message").hide(); $("#ep-review-explanation").hide(); $("#ep-review-show-hint").hide(); $(".ep-review-buttons").show(); } showInputInterface() { $("#ep-review-input-section").show(); $("#ep-review-answer").val("").prop("disabled", false); $("#ep-review-submit").show(); $("#ep-review-answer").focus(); this.setupInput(); } hideInputInterface() { $("#ep-review-input-section").hide(); $("#ep-review-submit").hide(); $("#ep-review-answer").prop("disabled", true); } async showCurrentItem() { const currentItem = this.reviewSession.currentItem; if (this.currentCard) { this.currentCard.remove(); } this.state = REVIEW_STATES.ANSWERING; this.hideReviewInterface(); this.currentCard = new ReviewCard(currentItem, REVIEW_STATES.ANSWERING); const $card = await this.currentCard.render(); // Clear and append the new card $("#ep-review-content").empty().append($card); // Ensure input is focused after rendering if (currentItem.type !== "recognition") { const $input = $("#ep-review-answer"); if ($input.length) { $input.focus(); this.setupInput(); } } } async handleAnswer() { const currentCard = this.currentCard; if (!currentCard) return; const userAnswer = currentCard.getAnswer(); if (!userAnswer) return; const isCorrect = this.reviewSession.checkAnswer(userAnswer); $(".ep-review-input-section, .ep-review-question, .ep-review-content, .kanji-option, #ep-review-submit").hide(); $(".ep-review-character").css({ marginBottom: "0" }); // Create result container if it doesn't exist if ($("#ep-review-result-container").length === 0) { $(".ep-review-card").append( $("<div>") .attr("id", "ep-review-result-container") .css({ ...styles.reviewModal.content, padding: 0 }) ); } if (isCorrect) { $("#ep-review-result-container") .empty() .append( $("<div>") .attr("id", "ep-review-result-message") .text("Correct!") .css({ ...styles.reviewModal.results.message, color: theme.colors.success, }) ); this.updateProgress(); setTimeout(() => this.handleNextItem(), 1000); } else { $("#ep-review-result-container") .empty() .append( $("<div>") .attr("id", "ep-review-result-message") .text("Incorrect") .css({ ...styles.reviewModal.results.message, color: theme.colors.error, }), $("<div>") .addClass("ep-review-buttons") .css({ display: "flex", gap: theme.spacing.md, justifyContent: "center" }) .append( $("<button>") .attr("id", "ep-review-show-hint") .text("Show Answer") .css({ ...styles.reviewModal.buttons.hint, minWidth: "120px" }), $("<button>") .attr("id", "ep-review-continue") .text("Continue Review") .css({ ...styles.reviewModal.buttons.submit, minWidth: "120px" }) ) ); } } async showHint() { await this.currentCard.updateState(REVIEW_STATES.REVIEWING); } async handleNextItem() { if (this.reviewSession.isComplete()) { this.showCompletionScreen(); return; } this.reviewSession.nextItem(); await this.showCurrentItem(); this.emit(REVIEW_EVENTS.NEXT_ITEM); } showCompletionScreen() { const progress = this.reviewSession.getProgress(); const mode = this.reviewSession.mode; let languageLearningQuotes; if (this.isKanjiSession) { languageLearningQuotes = [ "Every kanji you learn unlocks new understanding", "One character a day", "Continuation is power", "Each review strengthens your kanji recognition", "Little by little, steadily", "Each character you master opens new doors to understanding", "Your journey through the world of kanji grows stronger each day" ]; } else { languageLearningQuotes = [ "Every radical mastered unlocks new understanding", "Building your foundation, one radical at a time", "Mastering radicals today, recognizing kanji tomorrow", "Each radical review strengthens your foundation", "Little by little, your radical knowledge grows", "Each radical you master opens new paths of understanding", "Your journey through radicals grows stronger each day", "Steady progress in radicals paves the way forward", "Your radical knowledge builds the bridge to comprehension" ]; } const randomQuote = languageLearningQuotes[ Math.floor(Math.random() * languageLearningQuotes.length) ]; let completionMessage; switch (mode) { case PRACTICE_MODES.ENGLISH_TO_KANJI: completionMessage = `Review completed!<br>${progress.recognitionProgress}/${progress.total} Correct`; break; case PRACTICE_MODES.COMBINED: completionMessage = `Review completed!<br>` + `Meanings: ${progress.meaningProgress}/${progress.total/3} | ` + `Readings: ${progress.readingProgress}/${progress.total/3} | ` + `Recognition: ${progress.recognitionProgress}/${progress.total/3}`; break; case PRACTICE_MODES.STANDARD: completionMessage = `Review completed!<br>` + `Meanings: ${progress.meaningProgress}/${progress.total/2} | ` + `Readings: ${progress.readingProgress}/${progress.total/2}`; break; default: completionMessage = `Review completed!`; } const $completionContent = $("<div>") .css({ textAlign: "center", padding: theme.spacing.xl }) .append( $("<h1>") .html(completionMessage) .css({ ...styles.reviewModal.progress, marginBottom: theme.spacing.lg }), $("<p>") .text(`"${randomQuote}"`) .css({ color: theme.colors.gray[600], marginBottom: theme.spacing.xl, fontStyle: "italic" }), $("<div>") .css({ display: "flex", gap: theme.spacing.md, justifyContent: "center" }) .append( $("<button>") .text("Play Again") .css({ ...styles.reviewModal.buttons.submit, backgroundColor: theme.colors.success, minWidth: "120px" }) .on("click", this.handlePlayAgain), $("<button>") .text("Study Different Items") .css({ ...styles.reviewModal.buttons.submit, minWidth: "120px" }) .on("click", () => { this.emit(REVIEW_EVENTS.STUDY_AGAIN); }) ) ); $("#ep-review-content").empty().append($completionContent); this.emit(REVIEW_EVENTS.COMPLETE, { progress }); } async render() { this.$modal = $(reviewModalTemplate).appendTo("body"); this.$modal.css({ position: "fixed", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: "rgba(0, 0, 0, 0.9)", zIndex: theme.zIndex.modal, display: "flex", alignItems: "center", justifyContent: "center" }); $("#ep-review-modal-wrapper").css(styles.reviewModal.container); $("#ep-review-modal-header").css(styles.reviewModal.header); $("#ep-review-progress").css(styles.reviewModal.progress); $("#ep-review-exit").css(styles.reviewModal.buttons.exit); // Set up event delegation this.$modal .on("click", "#ep-review-submit", this.handleAnswer) .on("keypress", "#ep-review-answer", (e) => { if (e.which === 13) { this.handleAnswer(); } }) .on("click", "#ep-review-show-hint", this.showHint) .on("click", "#ep-review-continue", this.handleNextItem); $("#ep-review-exit").on("click", () => { this.emit(REVIEW_EVENTS.CLOSE); }); this.updateProgress(); await this.showCurrentItem(); return this.$modal; } remove() { if (this.currentCard) { this.currentCard.remove(); } const input = document.querySelector("#ep-review-answer"); if (input) { wanakana.unbind(input); } if (this.$modal) { this.$modal.remove(); this.$modal = null; } } } // Assumption: User has wkof.file_cache for the IndexedDB operations to work async function getCurrentUserLevel() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_VALUES.DB_NAME, 1); request.onsuccess = (event) => { const db = event.target.result; const transaction = db.transaction([DB_VALUES.FILE_STORE], "readonly"); const store = transaction.objectStore(DB_VALUES.FILE_STORE); const getUser = store.get(DB_VALUES.USER_RECORD); getUser.onsuccess = () => { const userData = getUser.result; resolve(userData.content.data.level); }; getUser.onerror = () => { reject(handleError("USER_LEVEL")); }; }; request.onerror = () => { reject(handleError("OPEN")); }; }); } async function getCurrentLevelRadicals() { try { const userLevel = await getCurrentUserLevel(); return new Promise((resolve, reject) => { const request = indexedDB.open(DB_VALUES.DB_NAME, 1); request.onsuccess = (event) => { const db = event.target.result; const transaction = db.transaction([DB_VALUES.FILE_STORE], "readonly"); const store = transaction.objectStore(DB_VALUES.FILE_STORE); const getSubjects = store.get(DB_VALUES.SUBJECT_RECORD); getSubjects.onsuccess = () => { const subjectsData = getSubjects.result; const currentLevelRadicals = Object.values(subjectsData.content.data) .filter(subject => subject.object === "radical" && subject.data.level === userLevel ) .map(radical => ({ id: radical.id, character: radical.data.characters, meaning: radical.data.meanings[0].meaning, documentationUrl: radical.data.document_url, meaningMnemonic: radical.data.meaning_mnemonic, svg: radical.data.character_images.find(img => img.content_type === "image/svg+xml" )?.url || null })); resolve(currentLevelRadicals); }; getSubjects.onerror = () => { reject(handleError("SUBJECT_DATA")); }; }; request.onerror = () => { reject(handleError("OPEN")); }; }); } catch (error) { console.error("Error in getCurrentLevelRadicals:", error); throw error; } } async function getCurrentLevelKanji() { return new Promise(async (resolve, reject) => { const userLevel = await getCurrentUserLevel(); const request = indexedDB.open('wkof.file_cache', 1); request.onsuccess = (event) => { const db = event.target.result; const transaction = db.transaction(['files'], 'readonly'); const store = transaction.objectStore('files'); Promise.all([ new Promise(resolve => { store.get('Apiv2.assignments').onsuccess = (e) => resolve(e.target.result.content.data); }), new Promise(resolve => { store.get('Apiv2.subjects').onsuccess = (e) => resolve(e.target.result.content.data); }) ]).then(([assignments, subjects]) => { const unlockedKanjiIds = new Set( Object.values(assignments) .filter(a => a.data.subject_type === "kanji") .map(a => a.data.subject_id) ); // Helper function to get radical information const getRadicalInfo = (radicalId) => { const radical = subjects[radicalId]; if (!radical) return null; return { id: radical.id, character: radical.data.characters, meaning: radical.data.meanings[0].meaning, svg: radical.data.character_images?.find(img => img.content_type === 'image/svg+xml' )?.url || null }; }; const currentLevelKanji = Object.values(subjects) .filter(subject => subject.object === "kanji" && subject.data.level === userLevel && unlockedKanjiIds.has(subject.id) ) .map(kanji => ({ id: kanji.id, character: kanji.data.characters, meanings: kanji.data.meanings.filter(m => m.accepted_answer), readings: kanji.data.readings.filter(r => r.accepted_answer), meaningMnemonic: kanji.data.meaning_mnemonic, meaningHint: kanji.data.meaning_hint, readingMnemonic: kanji.data.reading_mnemonic, readingHint: kanji.data.reading_hint, documentUrl: kanji.data.document_url, radicals: kanji.data.component_subject_ids .map(getRadicalInfo) .filter(Boolean), auxiliaryMeanings: kanji.data.auxiliary_meanings ?.filter(m => m.type === "whitelist") ?? [] })); resolve(currentLevelKanji); }); }; request.onerror = (error) => reject(error); }); } function handleError(type) { if (type == "OPEN") { return new Error(DB_ERRORS.OPEN); } if (type == "USER_LEVEL") { return new Error(DB_ERRORS.USER_LEVEL); } if (type == "SUBJECT_DATA") { return new Error(DB_ERRORS.SUBJECT_DATA); } } async function handleRadicalPractice() { try { disableScroll(); const radicals = await getCurrentLevelRadicals(); const selectionModal = new RadicalSelectionModal(radicals) .on(EVENTS$1.CLOSE, () => { enableScroll(); selectionModal.remove(); }) .on(EVENTS$1.START_REVIEW, (selectedRadicals) => { selectionModal.remove(); startRadicalReview(selectedRadicals); }); await selectionModal.render(); } catch (error) { console.error("Error in radical practice:", error); enableScroll(); } } async function startRadicalReview(selectedRadicals) { try { const session = { items: selectedRadicals, mode: "radical", }; const reviewSession = new RadicalReviewSession(session); reviewSession.nextItem(); const reviewModal = new ReviewSessionModal(reviewSession); reviewModal .on(REVIEW_EVENTS.CLOSE, () => { const progress = reviewSession.getProgress(); $("#ep-review-modal-header").remove(); $("#ep-review-content") .empty() .append( $("<div>") .css(styles.reviewModal.content) .append([ $("<p>", { css: { ...styles.reviewModal.progress, marginBottom: 0 }, text: `${progress.current}/${progress.total} Correct (${progress.percentComplete}%)` }), $("<p>", { css: { marginTop: 0, textAlign: "center" }, text: "Closing..." }) ]) ); setTimeout(() => { enableScroll(); reviewModal.remove(); }, 1000); }) .on(REVIEW_EVENTS.STUDY_AGAIN, () => { reviewModal.remove(); enableScroll(); handleRadicalPractice(); }); await reviewModal.render(); } catch (error) { console.error("Error in startRadicalReview:", error); enableScroll(); } } const MODAL_STATES = { READY: "ready" }; const EVENTS = { CLOSE: "close", START_REVIEW: "startReview" }; class KanjiGrid { constructor(kanji, onSelectionChange) { this.kanji = kanji; this.selectedKanji = new Set(); this.onSelectionChange = onSelectionChange; this.$container = null; } updateKanjiSelection($element, kanji, isSelected) { const baseStyles = { ...styles.practiceModal.radical.base, border: `2px solid ${isSelected ? theme.colors.kanji : 'rgba(255, 255, 255, 0.2)'}`, background: isSelected ? 'rgba(235, 1, 156, 0.2)' : 'rgba(255, 255, 255, 0.1)', transition: 'all 0.2s ease', '&:hover': { borderColor: theme.colors.kanji, background: isSelected ? 'rgba(235, 1, 156, 0.3)' : 'rgba(255, 255, 255, 0.2)' } }; $element.css(baseStyles); if (isSelected) { this.selectedKanji.add(kanji.id); } else { this.selectedKanji.delete(kanji.id); } this.onSelectionChange(this.selectedKanji); } toggleAllKanji(shouldSelect) { if (shouldSelect) { this.kanji.forEach(kanji => this.selectedKanji.add(kanji.id)); } else { this.selectedKanji.clear(); } this.$container.find(".kanji-selection-item").each((_, element) => { const $element = $(element); const kanjiId = parseInt($element.data("kanji-id")); this.updateKanjiSelection( $element, this.kanji.find(k => k.id === kanjiId), shouldSelect ); }); this.onSelectionChange(this.selectedKanji); } getSelectedKanji() { return Array.from(this.selectedKanji).map(id => this.kanji.find(kanji => kanji.id === id) ); } createKanjiElement(kanji) { const $element = $("<div>") .addClass("kanji-selection-item") .css({ ...styles.practiceModal.radical.base, position: "relative" }) .data("kanji-id", kanji.id) .append( $("<div>") .addClass("kanji-character") .css({ fontSize: theme.typography.fontSize.xl, color: theme.colors.white }) .text(kanji.character) ); $element .on("click", () => { const isCurrentlySelected = this.selectedKanji.has(kanji.id); this.updateKanjiSelection($element, kanji, !isCurrentlySelected); }); return $element; } async render() { this.$container = $("<div>") .css({ ...styles.practiceModal.grid, gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))" }); this.kanji.forEach(kanji => { const $element = this.createKanjiElement(kanji); this.$container.append($element); }); return this.$container; } } class KanjiSelectionModal { constructor(kanji, allUnlockedKanji) { this.kanji = kanji; this.allUnlockedKanji = allUnlockedKanji; this.selectedMode = PRACTICE_MODES.STANDARD; this.state = MODAL_STATES.READY; this.totalKanji = kanji.length; this.$modal = null; this.kanjiGrid = null; this.callbacks = new Map(); } on(event, callback) { this.callbacks.set(event, callback); return this; } emit(event, data) { const callback = this.callbacks.get(event); if (callback) callback(data); } validateSelection(selectedCount) { const minRequired = { [PRACTICE_MODES.STANDARD]: 1, [PRACTICE_MODES.ENGLISH_TO_KANJI]: 4, [PRACTICE_MODES.COMBINED]: 4 }; const required = minRequired[this.selectedMode]; const isValid = selectedCount >= required; const startButton = $("#ep-practice-modal-start"); if (isValid) { startButton .prop("disabled", false) .text(`Start Review (${selectedCount} Selected)`) .css({ ...styles.practiceModal.buttons.start.base, ...styles.practiceModal.buttons.start.kanji, opacity: 1, cursor: "pointer" }); } else { startButton .prop("disabled", true) .text(`Select at least ${required} kanji`) .css({ ...styles.practiceModal.buttons.start.base, ...styles.practiceModal.buttons.start.kanji, opacity: 0.5, cursor: "not-allowed" }); } } updateSelectAllButton(selectedCount) { const selectAllButton = $("#ep-practice-modal-select-all"); const isAllSelected = selectedCount === this.totalKanji; selectAllButton .text(isAllSelected ? "Deselect All" : "Select All") .css({ color: isAllSelected ? theme.colors.error : theme.colors.white, borderColor: isAllSelected ? theme.colors.error : theme.colors.white, '&:hover': { borderColor: isAllSelected ? theme.colors.error : theme.colors.kanji } }); } handleSelectionChange(selectedKanji) { const selectedCount = selectedKanji.size; this.updateSelectAllButton(selectedCount); this.validateSelection(selectedCount); } createModeSelector() { const $container = $("<div>") .css(styles.practiceModal.modeSelector.container); const $label = $("<div>") .text("Select Practice Mode") .css(styles.practiceModal.modeSelector.label); const $options = $("<div>") .css(styles.practiceModal.modeSelector.options); const createOption = (mode, label) => { const $option = $("<button>") .text(label) .css({ ...styles.practiceModal.modeSelector.option.base, ...(this.selectedMode === mode ? styles.practiceModal.modeSelector.option.selected : {}) }) .on("click", () => { $options.find("button").css(styles.practiceModal.modeSelector.option.base); $option.css({ ...styles.practiceModal.modeSelector.option.base, ...styles.practiceModal.modeSelector.option.selected }); this.selectedMode = mode; const currentSelection = this.kanjiGrid.getSelectedKanji(); this.validateSelection(currentSelection.length); }); return $option; }; $options.append( createOption(PRACTICE_MODES.STANDARD, "Standard Practice"), createOption(PRACTICE_MODES.ENGLISH_TO_KANJI, "English → Kanji"), createOption(PRACTICE_MODES.COMBINED, "Combined Practice") ); return $container.append($label, $options); } async render() { this.$modal = $(modalTemplate).appendTo("body"); $("#username").text($("p.user-summary__username:first").text()); this.$modal.css(styles.practiceModal.backdrop); $("#ep-practice-modal-welcome").css(styles.practiceModal.welcomeText.container); $("#ep-practice-modal-welcome h1").css(styles.practiceModal.welcomeText.username); $("#ep-practice-modal-welcome h2") .text("Please select the Kanji characters you would like to practice") .css({ color: theme.colors.white, opacity: 0.9 }); const $modeSelector = this.createModeSelector(); $modeSelector.insertAfter("#ep-practice-modal-welcome"); $("#ep-practice-modal-footer").css(styles.practiceModal.footer); $("#ep-practice-modal-content").css(styles.practiceModal.contentWrapper); // Initial disabled state with kanji color scheme $("#ep-practice-modal-start").css({ ...styles.practiceModal.buttons.start.base, ...styles.practiceModal.buttons.start.kanji, opacity: 0.5, cursor: "not-allowed" }); $("#ep-practice-modal-select-all").css({ ...styles.practiceModal.buttons.selectAll, '&:hover': { borderColor: theme.colors.kanji } }); $("#ep-practice-modal-close").css({ ...styles.practiceModal.buttons.exit, '&:hover': { borderColor: theme.colors.kanji, color: theme.colors.kanji } }); this.kanjiGrid = new KanjiGrid( this.kanji, this.handleSelectionChange.bind(this) ); const $grid = await this.kanjiGrid.render(); $("#ep-practice-modal-grid").replaceWith($grid); $("#ep-practice-modal-select-all").on("click", () => { const isSelectingAll = $("#ep-practice-modal-select-all").text() === "Select All"; this.kanjiGrid.toggleAllKanji(isSelectingAll); }); $("#ep-practice-modal-close").on("click", () => { this.emit(EVENTS.CLOSE); }); $("#ep-practice-modal-start").on("click", () => { const selectedKanji = this.kanjiGrid.getSelectedKanji(); const minRequired = { [PRACTICE_MODES.STANDARD]: 1, [PRACTICE_MODES.ENGLISH_TO_KANJI]: 4, [PRACTICE_MODES.COMBINED]: 4 }; if (selectedKanji.length >= minRequired[this.selectedMode]) { this.emit(EVENTS.START_REVIEW, { kanji: selectedKanji, mode: this.selectedMode, allUnlockedKanji: this.allUnlockedKanji }); } }); return this.$modal; } remove() { if (this.$modal) { this.$modal.remove(); this.$modal = null; } } } async function handleKanjiPractice() { try { disableScroll(); const kanji = await getCurrentLevelKanji(); const selectionModal = new KanjiSelectionModal(kanji, kanji) // Using current level kanji as unlocked list for now .on(EVENTS.CLOSE, () => { enableScroll(); selectionModal.remove(); }) .on(EVENTS.START_REVIEW, (data) => { selectionModal.remove(); startKanjiReview(data.kanji, data.mode, data.allUnlockedKanji); }); await selectionModal.render(); } catch (error) { console.error("Error in kanji practice:", error); enableScroll(); } } async function startKanjiReview(selectedKanji, mode, allUnlockedKanji) { try { const reviewSession = new KanjiReviewSession({ items: selectedKanji, mode: mode, allUnlockedKanji: allUnlockedKanji }); reviewSession.nextItem(); const reviewModal = new ReviewSessionModal(reviewSession); reviewModal .on(REVIEW_EVENTS.CLOSE, () => { const progress = reviewSession.getProgress(); $("#ep-review-modal-header").remove(); const closingContent = [$("<p>", { css: { marginTop: 0, textAlign: "center" }, text: "Closing..." })]; $("#ep-review-content") .empty() .append( $("<div>") .css(styles.reviewModal.content) .append((() => { if (reviewSession.mode === PRACTICE_MODES.STANDARD) { closingContent.unshift($("<p>", { css: { ...styles.reviewModal.progress, marginBottom: 0 }, text: `Meanings: ${progress.meaningProgress}/${progress.total/2} - Readings: ${progress.readingProgress}/${progress.total/2}` })); return closingContent; } else if (reviewSession.mode === PRACTICE_MODES.ENGLISH_TO_KANJI) { closingContent.unshift($("<p>", { css: { ...styles.reviewModal.progress, marginBottom: 0 }, text: `${progress.recognitionProgress}/${progress.total} Correct` })); return closingContent; } else { // COMBINATION PRACTICE_MODE closingContent.unshift($("<p>", { css: { ...styles.reviewModal.progress, marginBottom: 0 }, text: `Meanings: ${progress.meaningProgress}/${progress.total/3} | ` + `Readings: ${progress.readingProgress}/${progress.total/3} | ` + `Recognition: ${progress.recognitionProgress}/${progress.total/3}` })); return closingContent; } })()) ); setTimeout(() => { enableScroll(); reviewModal.remove(); }, 1000); }) .on(REVIEW_EVENTS.STUDY_AGAIN, () => { reviewModal.remove(); enableScroll(); handleKanjiPractice(); }); await reviewModal.render(); } catch (error) { console.error("Error in startKanjiReview:", error); enableScroll(); } } class PracticeButton { constructor(type) { this.type = type; this.buttonStyle = this.getButtonStyle(); this.handleClick = this.handleClick.bind(this); } getButtonStyle() { return this.type === PRACTICE_TYPES.RADICAL ? styles.buttons.practice.radical : styles.buttons.practice.kanji; } async handleClick() { try { if (this.type === PRACTICE_TYPES.RADICAL) { await handleRadicalPractice(); } else { await handleKanjiPractice(); } } catch (error) { console.error(`Error handling ${this.type} practice:`, error); } } render() { const $button = $("<button>") .attr("id", `ep-${this.type}-btn`) .text("Practice") .css(this.buttonStyle) .on("click", this.handleClick); const selector = `${SELECTORS.DIV_LEVEL_PROGRESS_CONTENT} ${SELECTORS.DIV_CONTENT_WRAPPER} ${SELECTORS.DIV_CONTENT_TITLE}`; // Doing a conditional check to add the practice button to the correct DIV. const targetSelector = this.type === PRACTICE_TYPES.RADICAL ? `${selector}:first` : `${selector}:last`; $button.appendTo(targetSelector); return $button; } } function initializePracticeButtons() { // First style the containers where the "PRACTICE" buttons be $(`${SELECTORS.DIV_LEVEL_PROGRESS_CONTENT} ${SELECTORS.DIV_CONTENT_WRAPPER} ${SELECTORS.DIV_CONTENT_TITLE}`) .css(styles.layout.contentTitle); const radicalButton = new PracticeButton(PRACTICE_TYPES.RADICAL); const kanjiButton = new PracticeButton(PRACTICE_TYPES.KANJI); radicalButton.render(); kanjiButton.render(); } $(document).ready(() => { initializePracticeButtons(); }); })(); //# sourceMappingURL=extra-practice.user.js.map
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址