diff --git a/manifest.json b/manifest.json index 67f410a..b6eb8b1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "openwords", "name": "OpenWords", - "version": "1.0.6", + "version": "1.0.7", "minAppVersion": "1.8.10", "description": "用于英语学习中背单词与单词管理的插件", "author": "insile", diff --git a/package.json b/package.json index f1ecee4..31280b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openwords", - "version": "1.0.6", + "version": "1.0.7", "description": "用于英语学习中背单词与单词管理的插件", "main": "main.js", "scripts": { diff --git a/src/main.ts b/src/main.ts index cda2d50..0a34aa9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,49 +1,16 @@ -import { MarkdownView, Notice, Plugin, TFile, normalizePath } from 'obsidian'; +import {MarkdownView, Notice, Plugin, TFile, TFolder, Vault} from 'obsidian'; +import { OpenWordsSettings, DEFAULT_SETTINGS } from "./settings/SettingData"; +import { OpenWordsSettingTab } from './settings/SettingTab'; +import { MainView, MAIN_VIEW, PageType } from './views/MainView'; +import { CardInfo } from './utils/Card'; import { supermemo, SuperMemoGrade } from 'supermemo'; -import { OpenWordsSettingTab } from './settings'; -import { LearningTypeModal } from './modals/LearningTypeModal'; -import { CardInfo } from './card'; import posTagger from 'wink-pos-tagger'; -// 插件设置 -interface OpenWordsSettings { - folderPath: string; // 用户指定的单词文件夹路径 - indexPath: string; // 用户指定的索引文件夹路径 - enableWords1: boolean; - enableWords2: boolean; - enableWords3: boolean; - enableWords4: boolean; - enableWords5: boolean; - enableWords6: boolean; - enableWords7: boolean; - enableWords8: boolean; - randomRatio: number; - maxEfactorForLink: number; -} - -// 默认设置 -const DEFAULT_SETTINGS: OpenWordsSettings = { - folderPath: normalizePath(""), - indexPath: normalizePath("索引"), - enableWords1: false, - enableWords2: false, - enableWords3: false, - enableWords4: false, - enableWords5: false, - enableWords6: false, - enableWords7: false, - enableWords8: false, - randomRatio: 0.7, - maxEfactorForLink: 2.6, -} - - // 插件主类 export default class OpenWords extends Plugin { settings: OpenWordsSettings; // 插件设置 settingsSnapshot: OpenWordsSettings; // 插件设置快照 - statusBarItem: HTMLElement; // 状态栏元素 tagger: posTagger = new posTagger(); // 词性标注器实例 allCards: Map = new Map(); // 所有单词 masterCards: Map = new Map(); // 掌握单词 @@ -53,22 +20,24 @@ export default class OpenWords extends Plugin { // 插件加载时执行的操作 async onload() { - // 加载插件设置参数, 状态栏, 设置选项卡, 左侧工具栏按钮 + // 加载插件设置参数, 设置选项卡, 左侧工具栏按钮, 注册主视图 await this.loadSettings(); - this.statusBarItem = this.addStatusBarItem(); this.addSettingTab(new OpenWordsSettingTab(this.app, this)); - this.addRibbonIcon('slack', 'OpenWords', async () => { - new LearningTypeModal(this.app, this).open(); - }); + this.addRibbonIcon('slack', 'OpenWords', async () => { await this.activateView(); }); + this.registerView(MAIN_VIEW, (leaf) => new MainView(leaf, this)); // 等待布局完成 this.app.workspace.onLayoutReady(async () => { - // 学习模式命令 + const normalized = this.settings.folderPath.endsWith("/") + ? this.settings.folderPath + : this.settings.folderPath + "/"; + + // 激活视图命令 this.addCommand({ id: 'LearningTypeModal', - name: '学习模式', + name: '学习视图', callback: () => { - new LearningTypeModal(this.app, this).open(); + this.activateView(); } }); // 添加双链命令 @@ -81,13 +50,13 @@ export default class OpenWords extends Plugin { }); // 监听单词文件创建事件 this.registerEvent(this.app.vault.on("create", (file: TFile) => { - if (file.path.endsWith(".md") && file.path.startsWith(this.settings.folderPath)) { + if (file.path.endsWith(".md") && file.path.startsWith(normalized)) { this.loadWordMetadata(file); } })); // 监听单词文件删除事件 this.registerEvent(this.app.vault.on("delete", (file: TFile) => { - if (file.path.endsWith(".md") && file.path.startsWith(this.settings.folderPath)) { + if (file.path.endsWith(".md") && file.path.startsWith(normalized)) { this.allCards.delete(file.basename); this.masterCards.delete(file.basename); this.enabledCards.delete(file.basename); @@ -98,17 +67,31 @@ export default class OpenWords extends Plugin { })); // 监听单词文件缓存修改事件 this.registerEvent(this.app.metadataCache.on("changed", (file) => { - if (file.path.endsWith(".md") && file.path.startsWith(this.settings.folderPath)) { + if (file.path.endsWith(".md") && file.path.startsWith(normalized)) { this.loadWordMetadata(file); } })); - // 扫描所有单词文件 + // 扫描所有单词文件, 更新状态栏 await this.scanAllNotes(); + this.updateStatusBar(); }); } + // 激活视图 + async activateView() { + const leaves = this.app.workspace.getLeavesOfType(MAIN_VIEW); + if (leaves.length === 0) { + await this.app.workspace.getLeaf(true).setViewState({ + type: MAIN_VIEW, + active: true, + }); + } else { + await this.app.workspace.revealLeaf(leaves[0]); + } + } + // 加载单词元数据 - async loadWordMetadata(file: TFile) { + async loadWordMetadata(file: TFile, single = true) { const allCards = this.allCards; const masterCards = this.masterCards; const enabledCards = this.enabledCards; @@ -127,8 +110,10 @@ export default class OpenWords extends Plugin { // 如果文件没有 FrontMatter,直接返回 if (!frontMatter) { + allCards.delete(file.basename); + if (single) { this.updateStatusBar(); } return; - } + } const tags: string[] = frontMatter.tags || []; const isMastered = frontMatter["掌握"] === true; @@ -151,7 +136,21 @@ export default class OpenWords extends Plugin { repetition: frontMatter["重复次数"], isMastered: isMastered, }; - + if ( + !card.front || + !card.path || + card.dueDate === undefined || + card.interval === undefined || + card.efactor === undefined || + card.repetition === undefined || + Number.isNaN(card.efactor) || + Number.isNaN(card.interval) || + Number.isNaN(card.repetition) + ) { + allCards.delete(file.basename); + if (single) { this.updateStatusBar(); } + return; + } const isMasteredOld = allCards.get(card.front)?.isMastered; // 更新所有单词 @@ -179,7 +178,8 @@ export default class OpenWords extends Plugin { await this.syncMetadataToCheckbox(file, newCheckbox, oldCheckbox); } - this.updateStatusBar() + // 如果是单个文件更新状态栏 + if (single) { this.updateStatusBar(); } } // 扫描所有单词文件 @@ -189,9 +189,11 @@ export default class OpenWords extends Plugin { this.enabledCards.clear(); this.newCards.clear(); this.dueCards.clear(); - + const normalized = this.settings.folderPath.endsWith("/") + ? this.settings.folderPath + : this.settings.folderPath + "/"; const files = this.app.vault.getMarkdownFiles(); - const filteredFiles = files.filter(file => file.path.startsWith(this.settings.folderPath)); + const filteredFiles = files.filter(file => file.path.startsWith(normalized)); if (filteredFiles.length === 0) { new Notice('指定的文件夹下没有 Markdown 文件!'); @@ -200,14 +202,22 @@ export default class OpenWords extends Plugin { } await Promise.all(filteredFiles.map(async (file) => { - await this.loadWordMetadata(file); + await this.loadWordMetadata(file, false); })); - new Notice(`扫描完成!共 ${this.allCards.size} 个单词`); // 完成后更新消息 + // new Notice(`扫描完成!共 ${this.allCards.size} 个单词`); // 完成后更新消息 } // 更新单词属性 - async updateCard(card: CardInfo, grade: SuperMemoGrade) { + async updateCard(card: CardInfo, grade: SuperMemoGrade, mode: PageType) { + if ( + (mode === 'new' && !this.newCards.has(card.front)) || + (mode === 'old' && !this.dueCards.has(card.front)) + ) { + new Notice("当前单词不属于本模式范围, 评分无效并跳过"); + return; + } + const result = supermemo(card, grade); const newDate = window.moment().add(result.interval, 'day').format('YYYY-MM-DD'); const file = this.app.vault.getFileByPath(card.path); @@ -217,7 +227,6 @@ export default class OpenWords extends Plugin { return; } - await this.app.fileManager.processFrontMatter(file, (frontMatter) => { frontMatter["到期日"] = newDate; frontMatter["间隔"] = result.interval; @@ -325,11 +334,15 @@ export default class OpenWords extends Plugin { wordRecords[level] = {}; } - const files = this.app.vault.getMarkdownFiles().filter(file => file.path.startsWith(wordsDir)); - for (const file of files) { - const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter; - if (!frontMatter) continue; // 如果没有 FrontMatter,跳过 - const tags: string[] = frontMatter.tags || []; + const folder = this.app.vault.getAbstractFileByPath(wordsDir); + if (!(folder instanceof TFolder)) return; // 确认目录存在 + + Vault.recurseChildren(folder, (file) => { + if (!(file instanceof TFile) || file.extension !== "md") return; + + const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter; + if (!frontMatter) return; // 如果没有 FrontMatter,跳过 + const tags: string[] = frontMatter.tags || []; const isMastered = frontMatter["掌握"] === true; for (const level of levels) { if (tags.includes(level)) { @@ -344,7 +357,7 @@ export default class OpenWords extends Plugin { } } } - } + }) const totalWordsList: Record = {}; // 记录每个级别的总单词数 // 遍历级别,生成索引 @@ -509,9 +522,15 @@ export default class OpenWords extends Plugin { } } + // 更新状态栏 updateStatusBar(){ - this.statusBarItem.setText(`${this.newCards.size} + ${this.dueCards.size} + ${this.allCards.size-this.enabledCards.size} = ${this.allCards.size}`); - } + this.app.workspace.getLeavesOfType(MAIN_VIEW).forEach(leaf => { + const view = leaf.view; + if (view instanceof MainView) { + void view.updateStatusBar(); + } + }); + } onunload() { diff --git a/src/modals/LearningModal.ts b/src/modals/LearningModal.ts deleted file mode 100644 index 6ef711a..0000000 --- a/src/modals/LearningModal.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { App, Modal, Notice, TFile, Setting, MarkdownRenderer, Component } from 'obsidian'; -import { SuperMemoGrade } from 'supermemo'; -import { CardInfo } from '../card'; -import OpenWords from '../main'; - - -// 背单词模态框 -export class LearningModal extends Modal { - plugin: OpenWords; - component: Component; - mode: 'new' | 'review'; - currentCard: CardInfo; - isRating: boolean = false; - currentRatingKey: string | null = null; - - constructor(app: App, plugin: OpenWords, mode: 'new' | 'review') { - super(app); - this.plugin = plugin; - this.mode = mode; - this.component = new Component(); - } - - onOpen() { - // 加载组件并选卡 - this.component.load() - this.pickNextCard(); - if (!this.currentCard) return; - - // 创建卡片容器和设置容器 - const { contentEl } = this; - contentEl.empty(); - const cardContainer = contentEl.createDiv({ cls: 'openwords-card' }); - const settingsContainer = contentEl.createDiv({ cls: 'openwords-card-settings' }); - this.renderSettings(settingsContainer, cardContainer); - this.renderCard(cardContainer); - - // 注册数字键和回车按键事件 - this.registerRatingKeyEvents(contentEl, cardContainer); - this.registerRenderKeyEvents(cardContainer); - } - - pickNextCard() { - const now = window.moment(); // 获取当前时间 - let pool: CardInfo[]; - - // 新词池是指所有新词, 旧词池是指所有已过期的旧词 - if (this.mode === 'new') { - pool = Array.from(this.plugin.newCards.values()); // 将 Set 转换为数组 - if (pool.length === 0) { - this.close(); - new Notice("已完成所有新词!"); - return; - } - // 新词排序:间隔降序,间隔相同则易记因子升序 - pool.sort((a, b) => { - const intervalA = Number(a.interval); - const intervalB = Number(b.interval); - if (intervalA !== intervalB) return intervalB - intervalA; // 降序 - const efactorA = Number(a.efactor); - const efactorB = Number(b.efactor); - return efactorA - efactorB; // 升序 - }); - } else { - pool = Array.from(this.plugin.dueCards.values()) - .filter(card => window.moment(card.dueDate).isBefore(now)) // 筛选已过期的单词 - if (pool.length === 0) { - this.close(); - new Notice("没有需要复习的卡片!"); - return; - } - // 旧词排序:易记因子升序,易记因子相同则重复次数升序 - pool.sort((a, b) => { - const efactorA = Number(a.efactor); - const efactorB = Number(b.efactor); - if (efactorA !== efactorB) return efactorA - efactorB; // 升序 - const repetitionA = Number(a.repetition); - const repetitionB = Number(b.repetition); - return repetitionA - repetitionB; // 升序 - }); - } - - // 单词调度 70% 纯随机, 30% 从排序后前 1% 中随机 - const randomMode = Math.random() < this.plugin.settings.randomRatio; - if (randomMode) { - this.currentCard = pool[Math.floor(Math.random() * pool.length)]; - } else { - const topN = Math.max(1, Math.ceil(pool.length / 100)); - const topPool = pool.slice(0, topN); - this.currentCard = topPool[Math.floor(Math.random() * topPool.length)]; - } - } - - renderSettings(settingsContainer: HTMLElement, cardContainer: HTMLDivElement) { - const grades: { grade: SuperMemoGrade, label: string }[] = [ - { grade: 0, label: '评分 0: 回答错误, 完全不会' }, - { grade: 1, label: '评分 1: 回答错误, 看到正确答案后感觉很熟悉' }, - { grade: 2, label: '评分 2: 回答错误, 看到正确答案后感觉很容易记住' }, - { grade: 3, label: '评分 3: 回答正确, 需要花费很大力气才能回忆起来' }, - { grade: 4, label: '评分 4: 回答正确, 需要经过一番犹豫才做出反应' }, - { grade: 5, label: '评分 5: 回答正确, 完美响应' }, - ]; - for (const { grade, label } of grades) { - new Setting(settingsContainer) - .setName(label) - .addButton(btn => btn - .setButtonText(String(grade)) - .onClick(() => this.rateCard(grade, cardContainer))); - } - } - - renderCard(cardContainer: HTMLDivElement) { - cardContainer.empty(); - const wordContent = cardContainer.createDiv({ cls: 'openwords-card-content' }); - wordContent.textContent = this.currentCard.front; - const file = this.plugin.app.vault.getFileByPath(this.currentCard.path); - if (!file) {return;} - const fileCache = this.app.metadataCache.getFileCache(file); - const frontMatter = fileCache?.frontmatter; - if (!frontMatter) {return;} - const tags: string[] = (frontMatter.tags || []).map((tag: string) => { - const parts = tag.split('/'); - return parts.length > 1 ? parts[1] : tag; - }); - const dueDate: string = frontMatter["到期日"] - const interval: string = frontMatter["间隔"] - const efactor: string = frontMatter["易记因子"] - const repetition: string = frontMatter["重复次数"] - - const metaDiv = cardContainer.createDiv({ cls: 'openwords-card-meta' }); - - // 第一行:标签 - const tagsDiv = metaDiv.createDiv({ cls: 'openwords-card-meta-tags' }); - tagsDiv.textContent = `标签: ${tags.join(', ')}`; - - // 第二行:其余元数据 - const infoDiv = metaDiv.createDiv({ cls: 'openwords-card-meta-info' }); - infoDiv.textContent = `易记因子: ${efactor} | 重复次数: ${repetition} | 到期日: ${dueDate} | 间隔: ${interval} `; - } - - registerRatingKeyEvents(contentEl: HTMLElement, cardContainer: HTMLDivElement) { - const handleKeydown = async (event: KeyboardEvent) => { - if (event.key >= '0' && event.key <= '5') { - if (this.isRating) return; - this.isRating = true; - this.currentRatingKey = event.key; - const grade = parseInt(event.key) as SuperMemoGrade; - await this.plugin.updateCard(this.currentCard, grade); - } - }; - const handleKeyup = async (event: KeyboardEvent) => { - if ( - this.isRating && - this.currentRatingKey !== null && - event.key === this.currentRatingKey - ) { - this.isRating = false; - this.currentRatingKey = null; - this.pickNextCard(); - this.renderCard(cardContainer); - } - }; - this.plugin.registerDomEvent(contentEl, 'keydown', handleKeydown); - this.plugin.registerDomEvent(contentEl, 'keyup', handleKeyup); - } - - registerRenderKeyEvents(cardContainer: HTMLDivElement) { - // 标记当前是否显示 Markdown 内容 - let isShowingMarkdown = false; - - const showMarkdown = async () => { - if (isShowingMarkdown) return; - const file = this.plugin.app.vault.getFileByPath(this.currentCard.path); - if (file instanceof TFile) { - const markdownContent = await this.plugin.app.vault.cachedRead(file); - cardContainer.empty(); - const markdownRenderContainer = cardContainer.createDiv({ cls: 'openwords-card-markdown' }); - await MarkdownRenderer.render( - this.app, - markdownContent, - markdownRenderContainer, - file.path, - this.component - ); - isShowingMarkdown = true; - } else { - new Notice('无法加载单词的 Markdown 文件!'); - } - }; - - const showWord = () => { - if (!isShowingMarkdown) return; - this.renderCard(cardContainer); - isShowingMarkdown = false; - }; - - cardContainer.addEventListener('mouseenter', showMarkdown); - cardContainer.addEventListener('mouseleave', showWord); - - // 按下回车显示内容,释放回车显示单词 - const handleKeyDown = async (event: KeyboardEvent) => { - if (event.key === 'Tab') { - event.preventDefault(); - await showMarkdown(); - } - }; - const handleKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Tab') { - event.preventDefault(); - showWord(); - } - }; - this.plugin.registerDomEvent(cardContainer, 'keydown', handleKeyDown); - this.plugin.registerDomEvent(cardContainer, 'keyup', handleKeyUp); - - // 让卡片容器可聚焦以接收键盘事件 - cardContainer.tabIndex = 0; - cardContainer.focus(); - } - - async rateCard(grade: SuperMemoGrade, cardContainer: HTMLDivElement) { - if (this.isRating) return; - this.isRating = true; - await this.plugin.updateCard(this.currentCard, grade); - this.pickNextCard(); - this.renderCard(cardContainer); - this.isRating = false; - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - this.component.unload(); - } - -} diff --git a/src/modals/LearningTypeModal.ts b/src/modals/LearningTypeModal.ts deleted file mode 100644 index 3e5f9c0..0000000 --- a/src/modals/LearningTypeModal.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { App, Modal, Notice, Setting } from 'obsidian'; -import { LearningModal } from './LearningModal'; -import { WordListModal } from './WordListModal'; -import { SpellingModal } from './SpellingModal'; -import OpenWords from '../main'; - - -// 学习模式模态框 -export class LearningTypeModal extends Modal { - plugin: OpenWords; - - constructor(app: App, plugin: OpenWords) { - super(app); - this.plugin = plugin; - } - - onOpen() { - this.modalEl.addClass('openwords-type-modal'); // 添加自定义样式类 - const { contentEl } = this; - contentEl.empty(); - - contentEl.createDiv({ cls: 'openwords-type-title', text: '选择学习模式' }); - const buttonContainer = contentEl.createDiv({ cls: 'openwords-type-button-grid' }); - - new Setting(buttonContainer) - .setName(`学习新词 ( 重复次数为零, 剩余 ${this.plugin.newCards.size} )`) - .addButton(btn => btn - .setButtonText("开始") - .onClick(() => { - if (this.plugin.newCards.size > 0) { - this.close(); - new LearningModal(this.app, this.plugin, 'new').open(); - } else { - new Notice("没有新词可学了!"); - } - })); - - new Setting(buttonContainer) - .setName(`复习旧词 ( 重复次数不为零, 共计 ${this.plugin.dueCards.size}, 过期 ${Array.from(this.plugin.dueCards.values()) - .filter(card => window.moment(card.dueDate).isBefore(window.moment())).length} )`) - .addButton(btn => btn - .setButtonText("开始") - .onClick(() => { - if (this.plugin.dueCards.size > 0) { - this.close(); - new LearningModal(this.app, this.plugin, 'review').open(); - } else { - new Notice("没有需要复习的卡片!"); - } - })); - - new Setting(buttonContainer) - .setName(`掌握列表 ( 所有单词, 掌握 ${this.plugin.masterCards.size} )`) - .addButton(btn => btn - .setButtonText("开始") - .onClick(() => { - this.close(); - new WordListModal(this.app, this.plugin).open(); - })); - - new Setting(buttonContainer) - .setName('默写单词 ( 重复次数不为零 )') - .addButton(btn => btn - .setButtonText('开始') - .onClick(() => { - this.close(); - new SpellingModal(this.app, this.plugin).open(); - })); - - new Setting(buttonContainer) - .setName(`添加双链 ( 启用单词中易记因子 <= ${this.plugin.settings.maxEfactorForLink} )`) - .addButton(btn => btn - .setButtonText("开始") - .onClick(async () => { - this.close(); - await this.plugin.addDoubleBrackets(); - })); - } -} diff --git a/src/modals/SpellingModal.ts b/src/modals/SpellingModal.ts deleted file mode 100644 index b1d2bf6..0000000 --- a/src/modals/SpellingModal.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { App, Modal, MarkdownRenderer, TFile, Component, Notice } from 'obsidian'; -import { CardInfo } from '../card'; -import OpenWords from '../main'; - - -// 默写单词模态框 -export class SpellingModal extends Modal { - plugin: OpenWords; - component: Component; - currentCard: CardInfo | null = null; - hasPeeked: boolean = false; // 是否看过答案 - errorCount: number = 0; // 本轮错误次数 - - constructor(app: App, plugin: OpenWords) { - super(app); - this.plugin = plugin; - this.component = new Component(); - } - - async onOpen() { - // 加载组件 - const { contentEl } = this; - contentEl.empty(); - this.component.load() - this.modalEl.addClass('openwords-spelling'); // 添加样式类 - - const title = contentEl.createDiv({ cls: 'openwords-spelling-title', text: '默写单词' }); - const wordMeaningContainer = contentEl.createDiv({ cls: 'openwords-spelling-meaning' }); - const inputContainer = contentEl.createDiv({ cls: 'openwords-spelling-input' }); - const feedbackContainer = contentEl.createDiv({ cls: 'openwords-spelling-feedback' }); - - // 初始化第一个单词 - await this.pickNextCard(wordMeaningContainer, inputContainer, feedbackContainer); - - const handleKeydown = (event: KeyboardEvent) => { - if (event.key === 'Tab') { - event.preventDefault(); // 阻止页面滚动 - if (this.currentCard) { - title.setText(`${this.currentCard.front}`); // 显示答案 - this.hasPeeked = true; // 标记已看答案 - } - } - }; - - const handleKeyup = (event: KeyboardEvent) => { - if (event.key === 'Tab') { - event.preventDefault(); // 阻止页面滚动 - title.setText('默写单词'); // 恢复标题 - } - }; - this.plugin.registerDomEvent(contentEl, 'keydown', handleKeydown); - this.plugin.registerDomEvent(contentEl, 'keyup', handleKeyup); - } - - async pickNextCard( - wordMeaningContainer: HTMLElement, - inputContainer: HTMLElement, - feedbackContainer: HTMLElement - ) { - const cards = Array.from(this.plugin.dueCards.values()); - if (cards.length === 0) { - feedbackContainer.setText('没有更多单词了!'); - return; - } - this.hasPeeked = false; - this.errorCount = 0; - - // 从易记因子最低的50%中随机选择一个单词 - const sorted = cards.slice().sort((a, b) => a.efactor - b.efactor); - const half = Math.ceil(sorted.length / 2); - const pool = sorted.slice(0, half); - this.currentCard = pool[Math.floor(Math.random() * pool.length)]; - - // 渲染单词的词义 - const file = this.plugin.app.vault.getFileByPath(this.currentCard.path); - if (file instanceof TFile) { - const content = await this.plugin.app.vault.cachedRead(file); - const match = content.match(/##### 词义\n(?:- .*\n?)*/g); - wordMeaningContainer.empty(); // 清空之前的内容 - if (match) { - await MarkdownRenderer.render( - this.app, - match[0], - wordMeaningContainer, - file.path, - this.component - ); - } else { - wordMeaningContainer.setText('未找到词义'); - } - } - - // 清空输入容器并生成字母输入框 - inputContainer.empty(); - feedbackContainer.setText(''); - if (this.currentCard) { - const word = this.currentCard.front; - const inputFields: HTMLInputElement[] = []; - - // 为每个字母生成一个输入框 - for (let i = 0; i < word.length; i++) { - const inputField = inputContainer.createEl('input', { - type: 'text', - cls: 'openwords-spelling-letter', - }); - - inputFields.push(inputField); - - // 自动跳转到下一个输入框 - inputField.addEventListener('input', () => { - if (inputField.value.length === 1 && i < word.length - 1) { - inputFields[i + 1].focus(); - } - this.checkSpelling(inputFields, word, feedbackContainer, wordMeaningContainer, inputContainer); - }); - - // 支持使用退格键返回上一个输入框 - inputField.addEventListener('keydown', (event) => { - if (event.key === 'Backspace' && inputField.value === '' && i > 0) { - inputFields[i - 1].focus(); - } - }); - } - - // 聚焦第一个输入框 - inputFields[0].focus(); - } - } - - async checkSpelling( - inputFields: HTMLInputElement[], - word: string, - feedbackContainer: HTMLElement, - wordMeaningContainer: HTMLElement, - inputContainer: HTMLElement - ) { - const userInput = inputFields.map((field) => field.value).join(''); - if (userInput.length === word.length) { - if (userInput.toLowerCase() === word.toLowerCase()) { - feedbackContainer.setText('正确!'); - if (this.currentCard) { - let efactor = this.currentCard.efactor; - if (this.hasPeeked) { - efactor -= 0.02; - } else if (this.errorCount === 0) { - efactor += 0.15; - } else { - efactor += 0.05; - } - if (efactor < 1.3) efactor = 1.3; - this.currentCard.efactor = efactor; - - // 同步到 frontmatter - const file = this.plugin.app.vault.getFileByPath(this.currentCard.path); - if (file instanceof TFile) { - await this.plugin.app.fileManager.processFrontMatter(file, (frontMatter) => { - frontMatter["易记因子"] = Math.round(efactor * 100); - }); - } - - new Notice(`${this.currentCard.front} \n易记因子: ${efactor.toFixed(2)} \n重复次数: ${this.currentCard.repetition} \n间隔: ${this.currentCard.interval} \n到期日: ${this.currentCard.dueDate}`); - - } - setTimeout(async () => { - await this.pickNextCard(wordMeaningContainer, inputContainer, feedbackContainer); - }, 500); - } else { - feedbackContainer.setText('错误,请重试!'); - this.errorCount += 1; - inputFields.forEach((field) => (field.value = '')); // 清空所有输入框 - inputFields[0].focus(); // 聚焦第一个输入框 - } - } - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - this.component.unload(); - } -} diff --git a/src/modals/WordListModal.ts b/src/modals/WordListModal.ts deleted file mode 100644 index 397170e..0000000 --- a/src/modals/WordListModal.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { App, Modal, Notice, Setting, MarkdownRenderer, TFile, Component } from 'obsidian'; -import OpenWords from '../main'; - -// 单词列表模态框 -export class WordListModal extends Modal { - plugin: OpenWords; - component: Component; - selectedLetter: string | null = null; // 当前选中的首字母 - selectedLevel: string | null = null; // 当前选中的级别 - - constructor(app: App, plugin: OpenWords) { - super(app); - this.plugin = plugin; - this.component = new Component(); - } - - async onOpen() { - this.modalEl.addClass('openwords-list'); - const { contentEl } = this; - contentEl.empty(); - contentEl.createDiv({ cls: 'openwords-list-title', text: '掌握列表' }); - const filterContainer = contentEl.createDiv({ cls: 'openwords-list-filters' }); - const listContainer = contentEl.createDiv({ cls: 'openwords-list-container' }); - this.component.load() - // 添加首字母筛选 - new Setting(filterContainer) - .setName('首字母筛选') - .addDropdown(dropdown => { - dropdown.addOption('', '全部'); - for (let i = 65; i <= 90; i++) { - const letter = String.fromCharCode(i); - dropdown.addOption(letter, letter); - } - dropdown.setValue(this.selectedLetter || ''); - dropdown.onChange(value => { - this.selectedLetter = value || null; - this.renderWordList(listContainer); - }); - }); - - // 添加级别筛选 - new Setting(filterContainer) - .setName('级别筛选') - .addDropdown(dropdown => { - dropdown.addOption('', '全部'); - const levels = [ - '级别/小学', '级别/中考', '级别/高考四级', '级别/考研', - '级别/六级', '级别/雅思', '级别/托福', '级别/GRE' - ]; - levels.forEach(level => dropdown.addOption(level, level.split('/')[1])); - dropdown.setValue(this.selectedLevel || ''); - dropdown.onChange(value => { - this.selectedLevel = value || null; - this.renderWordList(listContainer); - }); - }); - - // 初始不渲染单词列表 - listContainer.createDiv({ text: '请选择筛选条件以查看单词列表。' }); - } - - renderWordList(container: HTMLElement) { - container.empty(); - - // 如果没有选择筛选条件,则不渲染内容 - if (!this.selectedLetter && !this.selectedLevel) { - container.createDiv({ text: '请选择筛选条件以查看单词列表。' }); - return; - } - - - // 根据筛选条件过滤单词 - const filteredWords = Array.from(this.plugin.allCards.values()).filter(card => { - - const file = this.plugin.app.vault.getFileByPath(card.path); - if (file instanceof TFile) { - const matchesLetter = this.selectedLetter - ? card.front.startsWith(this.selectedLetter.toLowerCase()) - : true; - const matchesLevel = this.selectedLevel - ? this.plugin.app.metadataCache.getFileCache(file)?.frontmatter?.tags?.includes(this.selectedLevel) - : true; - return matchesLetter && matchesLevel; - } - }).sort((a, b) => a.front.localeCompare(b.front)); // 按字母顺序排序 - - // 渲染单词列表 - if (filteredWords.length === 0) { - container.createDiv({ text: '没有符合条件的单词。' }); - return; - } - - filteredWords.forEach(card => { - const wordItem = container.createDiv({ cls: 'openwords-list-item' }); - // 左侧显示单词 - const wordText = wordItem.createDiv({ cls: 'openwords-list-text', text: card.front }); - const toggleContainer = wordItem.createDiv({ cls: 'toggle-container' }); - - new Setting(toggleContainer) - // .setName(card.front) - .addToggle(toggle => { - toggle.setValue(card.isMastered) // 设置初始复选框状态 - .onChange(async (value) => { - const file = this.plugin.app.vault.getFileByPath(card.path); - if (!(file instanceof TFile)) return; // 如果文件不存在,直接返回 - // 更新单词的掌握状态 - await this.plugin.app.fileManager.processFrontMatter( - file, (frontMatter) => {frontMatter["掌握"] = value;} - ); - new Notice(`单词 "${card.front}" 已更新为 ${value ? '掌握' : '未掌握'}`); - }); - }); - // 创建 Markdown 预览容器 - const previewContainer = wordText.createDiv({ cls: 'openwords-list-preview' }); - - // 添加鼠标悬停事件以渲染 Markdown 内容 - wordText.addEventListener('mouseenter', async () => { - const file = this.plugin.app.vault.getFileByPath(card.path); - if (file instanceof TFile) { - const markdownContent = await this.plugin.app.vault.cachedRead(file); - previewContainer.empty(); - await MarkdownRenderer.render( - this.app, - markdownContent, - previewContainer, - file.path, - this.component - ); - } - }); - - // 鼠标离开时清空预览内容 - wordText.addEventListener('mouseleave', () => { - previewContainer.empty(); - }); - }); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - this.component.unload(); - } -} diff --git a/src/settings/SettingData.ts b/src/settings/SettingData.ts new file mode 100644 index 0000000..8c06e85 --- /dev/null +++ b/src/settings/SettingData.ts @@ -0,0 +1,35 @@ +import { normalizePath } from "obsidian"; + + +// OpenWords 插件设置接口 +export interface OpenWordsSettings { + folderPath: string; + indexPath: string; + enableWords1: boolean; + enableWords2: boolean; + enableWords3: boolean; + enableWords4: boolean; + enableWords5: boolean; + enableWords6: boolean; + enableWords7: boolean; + enableWords8: boolean; + randomRatio: number; + maxEfactorForLink: number; +} + + +// OpenWords 插件默认设置 +export const DEFAULT_SETTINGS: OpenWordsSettings = { + folderPath: normalizePath(""), + indexPath: normalizePath("索引"), + enableWords1: false, + enableWords2: false, + enableWords3: false, + enableWords4: false, + enableWords5: false, + enableWords6: false, + enableWords7: false, + enableWords8: false, + randomRatio: 0.7, + maxEfactorForLink: 2.6, +}; diff --git a/src/settings.ts b/src/settings/SettingTab.ts similarity index 97% rename from src/settings.ts rename to src/settings/SettingTab.ts index b691bc3..b9c1ff3 100644 --- a/src/settings.ts +++ b/src/settings/SettingTab.ts @@ -1,6 +1,7 @@ import { App, Notice, PluginSettingTab, Setting, normalizePath } from 'obsidian'; -import { FolderSuggest } from './inputsuggest'; -import OpenWords from './main'; +import { FolderSuggest } from '../utils/InputSuggest'; +import OpenWords from '../main'; + // 插件设置选项卡 export class OpenWordsSettingTab extends PluginSettingTab { @@ -117,7 +118,8 @@ export class OpenWordsSettingTab extends PluginSettingTab { this.plugin.settings = {...this.plugin.settingsSnapshot}; // 更新插件设置 await this.plugin.saveSettings(); // 保存设置 await this.plugin.scanAllNotes(); // 重新缓存单词 - + new Notice(`扫描完成!共 ${this.plugin.allCards.size} 个单词`); // 完成后更新消息 + this.plugin.updateStatusBar(); // 更新状态栏 } catch (error) { console.error('缓存过程中发生错误:', error); new Notice('缓存失败,请检查控制台日志!'); diff --git a/src/card.ts b/src/utils/Card.ts similarity index 99% rename from src/card.ts rename to src/utils/Card.ts index b527d3d..e1c2caf 100644 --- a/src/card.ts +++ b/src/utils/Card.ts @@ -1,5 +1,6 @@ import {SuperMemoItem} from 'supermemo'; + // 单词卡片信息 export interface CardInfo extends SuperMemoItem { front: string; diff --git a/src/inputsuggest.ts b/src/utils/InputSuggest.ts similarity index 94% rename from src/inputsuggest.ts rename to src/utils/InputSuggest.ts index 67c2b77..5962393 100644 --- a/src/inputsuggest.ts +++ b/src/utils/InputSuggest.ts @@ -1,6 +1,7 @@ import { App, TAbstractFile, TFolder, AbstractInputSuggest } from 'obsidian'; +// 输入建议类, 用于文件夹路径的自动补全 export class FolderSuggest extends AbstractInputSuggest { inputEl: HTMLInputElement; constructor(app: App, inputEl: HTMLInputElement) { diff --git a/src/views/LearningPage.ts b/src/views/LearningPage.ts new file mode 100644 index 0000000..dfdecd6 --- /dev/null +++ b/src/views/LearningPage.ts @@ -0,0 +1,215 @@ +import { Setting, Notice, MarkdownRenderer, TFile } from 'obsidian'; +import { SuperMemoGrade } from 'supermemo'; +import { CardInfo } from '../utils/Card'; +import { MainView } from './MainView'; + +export async function LearningPage(this: MainView) { + + this.currentLearingCard = this.pickNextLCard(); + if (!this.currentLearingCard) return; + + this.viewContainer.empty(); + const learningContainer = this.viewContainer.createDiv({ cls: 'openwords-learning-container' }); + const backBtn = learningContainer.createEl('button', { cls: 'openwords-back-btn' }); + const cardContainer = learningContainer.createDiv({ cls: 'openwords-learning-card' }); + const settingsContainer = learningContainer.createDiv({ cls: 'openwords-learning-settings' }); + + backBtn.textContent = '返回'; + backBtn.onclick = () => { + this.page = 'type'; + this.render(); + }; + + this.renderCard(cardContainer); + this.renderSettings(cardContainer, settingsContainer); + + this.registerRatingKeyEvents(learningContainer, cardContainer); + this.registerRenderKeyEvents(learningContainer, cardContainer); + learningContainer.tabIndex = 0; + learningContainer.focus(); +} + +// 选卡逻辑 +export function pickNextLCard(this: MainView) { + const now = window.moment(); + let pool: CardInfo[]; + if (this.page === 'new') { // 新词排序:间隔降序,间隔相同则易记因子升序 + pool = Array.from(this.plugin.newCards.values()); + if (pool.length === 0) { + new Notice("已完成所有新词!"); + this.page = 'type'; + void this.render(); + return null; + } + pool.sort((a, b) => { + const intervalA = Number(a.interval); + const intervalB = Number(b.interval); + if (intervalA !== intervalB) return intervalB - intervalA; + const efactorA = Number(a.efactor); + const efactorB = Number(b.efactor); + return efactorA - efactorB; + }); + } else { // 旧词排序:易记因子升序,易记因子相同则重复次数升序 + pool = Array.from(this.plugin.dueCards.values()) + .filter(card => window.moment(card.dueDate).isBefore(now)); + if (pool.length === 0) { + new Notice("没有需要复习的卡片!"); + this.page = 'type'; + void this.render(); + return null; + } + pool.sort((a, b) => { + const efactorA = Number(a.efactor); + const efactorB = Number(b.efactor); + if (efactorA !== efactorB) return efactorA - efactorB; + const repetitionA = Number(a.repetition); + const repetitionB = Number(b.repetition); + return repetitionA - repetitionB; + }); + } + const randomMode = Math.random() < this.plugin.settings.randomRatio; + if (randomMode) { + return pool[Math.floor(Math.random() * pool.length)]; + } else { + const topN = Math.max(1, Math.ceil(pool.length / 100)); + const topPool = pool.slice(0, topN); + return topPool[Math.floor(Math.random() * topPool.length)]; + } +} + +// 卡片内容渲染 +export function renderCard(this: MainView, cardContainer: HTMLDivElement) { + cardContainer.empty(); + if (!this.currentLearingCard) return; + const wordContent = cardContainer.createDiv({ cls: 'openwords-card-content' }); + wordContent.textContent = this.currentLearingCard.front; + const file = this.plugin.app.vault.getFileByPath(this.currentLearingCard.path); + if (!file) { return; } + const fileCache = this.plugin.app.metadataCache.getFileCache(file); + const frontMatter = fileCache?.frontmatter; + if (!frontMatter) { return; } + const tags: string[] = (frontMatter.tags || []).map((tag: string) => { + const parts = tag.split('/'); + return parts.length > 1 ? parts[1] : tag; + }); + const dueDate: string = frontMatter["到期日"]; + const interval: string = frontMatter["间隔"]; + const efactor: string = frontMatter["易记因子"]; + const repetition: string = frontMatter["重复次数"]; + + const metaDiv = cardContainer.createDiv({ cls: 'openwords-card-meta' }); + const tagsDiv = metaDiv.createDiv({ cls: 'openwords-card-meta-tags' }); + tagsDiv.textContent = `标签: ${tags.join(', ')}`; + const infoDiv = metaDiv.createDiv({ cls: 'openwords-card-meta-info' }); + infoDiv.textContent = `易记因子: ${efactor} | 重复次数: ${repetition} | 到期日: ${dueDate} | 间隔: ${interval} `; +} + +// 评分按钮渲染 +export function renderSettings(cardContainer: HTMLDivElement, settingsContainer: HTMLElement) { + const grades: { grade: SuperMemoGrade, label: string }[] = [ + { grade: 0, label: '评分 1: 回答错误, 完全不会' }, + { grade: 1, label: '评分 2: 回答错误, 看到正确答案后感觉很熟悉' }, + { grade: 2, label: '评分 3: 回答错误, 看到正确答案后感觉很容易记住' }, + { grade: 3, label: '评分 4: 回答正确, 需要花费很大力气才能回忆起来' }, + { grade: 4, label: '评分 5: 回答正确, 需要经过一番犹豫才做出反应' }, + { grade: 5, label: '评分 6: 回答正确, 完美响应' }, + ]; + for (const { grade, label } of grades) { + new Setting(settingsContainer) + .setName(label) + .addButton(btn => { + btn.setButtonText(String(grade+1)); + btn.buttonEl.setAttribute('data-grade', String(grade+1)); + btn.onClick(() => this.rateCard(grade, cardContainer)); + }); + } +} + +// 评分逻辑 +export async function rateCard(this: MainView, grade: SuperMemoGrade, cardContainer: HTMLDivElement) { + if (this.isRating || !this.currentLearingCard) return; + this.isRating = true; + await this.plugin.updateCard(this.currentLearingCard, grade, this.page); + this.currentLearingCard = this.pickNextLCard(); + this.renderCard(cardContainer); + this.isRating = false; +} + +// 数字键评分 +export function registerRatingKeyEvents(this: MainView, learningContainer: HTMLDivElement, cardContainer: HTMLDivElement) { + const handleKeydown = async (event: KeyboardEvent) => { + if (event.key >= '1' && event.key <= '6') { + if (this.isRating || !this.currentLearingCard) return; + this.isRating = true; + this.currentRatingKey = event.key; + const btn = learningContainer.querySelector(`button[data-grade="${event.key}"]`); + if (btn) btn.classList.add('active'); + const grade = parseInt(event.key)-1 as SuperMemoGrade; + await this.plugin.updateCard(this.currentLearingCard, grade, this.page); + } + }; + const handleKeyup = async (event: KeyboardEvent) => { + if ( + this.isRating && + this.currentRatingKey !== null && + event.key === this.currentRatingKey + ) { + const btn = learningContainer.querySelector(`button[data-grade="${event.key}"]`); + if (btn) btn.classList.remove('active'); + this.isRating = false; + this.currentRatingKey = null; + this.currentLearingCard = this.pickNextLCard(); + this.renderCard(cardContainer); + } + }; + this.plugin.registerDomEvent(learningContainer, 'keydown', handleKeydown); + this.plugin.registerDomEvent(learningContainer, 'keyup', handleKeyup); +} + +// 答案渲染逻辑 +export function registerRenderKeyEvents(this: MainView, learningContainer: HTMLDivElement, cardContainer: HTMLDivElement) { + let isShowingMarkdown = false; + const showMarkdown = async () => { + if (isShowingMarkdown || !this.currentLearingCard) return; + const file = this.plugin.app.vault.getFileByPath(this.currentLearingCard.path); + if (file instanceof TFile) { + const markdownContent = await this.plugin.app.vault.cachedRead(file); + cardContainer.empty(); + const markdownRenderContainer = cardContainer.createDiv({ cls: 'openwords-card-markdown' }); + await MarkdownRenderer.render( + this.plugin.app, + markdownContent, + markdownRenderContainer, + file.path, + this.component + ); + isShowingMarkdown = true; + } else { + new Notice('无法加载单词的 Markdown 文件!'); + } + }; + const showWord = () => { + if (!isShowingMarkdown) return; + this.renderCard(cardContainer); + isShowingMarkdown = false; + }; + cardContainer.addEventListener('mouseenter', showMarkdown); + cardContainer.addEventListener('mouseleave', showWord); + + const handleKeyDown = async (event: KeyboardEvent) => { + if (event.key === 'Tab') { + event.preventDefault(); + await showMarkdown(); + } + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Tab') { + event.preventDefault(); + showWord(); + } + }; + this.plugin.registerDomEvent(learningContainer, 'keydown', handleKeyDown); + this.plugin.registerDomEvent(learningContainer, 'keyup', handleKeyUp); + +} + diff --git a/src/views/MainView.ts b/src/views/MainView.ts new file mode 100644 index 0000000..1fc8aa0 --- /dev/null +++ b/src/views/MainView.ts @@ -0,0 +1,92 @@ +import { ItemView, WorkspaceLeaf, Component } from 'obsidian'; +import { TypePage } from './TypePage'; +import { SpellingPage, pickNextSCard, renderInput, checkSpelling } from './SpellingPage'; +import { LearningPage, pickNextLCard, renderCard, renderSettings, rateCard, registerRatingKeyEvents, registerRenderKeyEvents } from './LearningPage'; +import { CardInfo } from '../utils/Card'; +import OpenWords from '../main'; + +export const MAIN_VIEW = "openwords-view"; +export type PageType = 'type' | 'new' | 'old' | 'spelling'; + +export class MainView extends ItemView { + // 主视图 + plugin: OpenWords; + page: PageType = 'type'; + component: Component; + viewContainer: HTMLDivElement; + statusBarEl: HTMLDivElement; + // 学习分类页面 + TypePage = TypePage; + // 单词学习页面 + currentLearingCard: CardInfo | null = null; + isRating = false; + currentRatingKey: string | null = null; + LearningPage = LearningPage + pickNextLCard = pickNextLCard; + renderCard = renderCard; + renderSettings = renderSettings; + rateCard = rateCard; + registerRatingKeyEvents = registerRatingKeyEvents; + registerRenderKeyEvents = registerRenderKeyEvents; + // 单词拼写页面 + currentSpellingCard: CardInfo | null = null; + selectedLetter: string | null = null; + selectedLevel: string | null = null; + hasPeeked = false; + errorCount = 0; + SpellingPage = SpellingPage + pickNextSCard = pickNextSCard + renderInput = renderInput + checkSpelling = checkSpelling; + + constructor(leaf: WorkspaceLeaf, plugin: OpenWords) { + super(leaf); + this.plugin = plugin; + this.component = new Component(); + } + + getViewType() { return MAIN_VIEW; } + getDisplayText() { return "OpenWords"; } + getIcon(): string { return "slack"; } + + async onOpen() { + this.component.load(); + const container = this.containerEl.children[1]; + this.viewContainer = container.createDiv({ cls: 'openwords-view-container' }); + this.statusBarEl = container.createDiv({ cls: 'openwords-view-statusbar' }); + await this.render(); + await this.updateStatusBar(); + } + + async onClose() { + this.containerEl.empty(); + this.component.unload(); + } + + async render() { + this.viewContainer.empty(); + + switch (this.page) { + case 'type': + this.TypePage(); + break; + case 'new': + await this.LearningPage(); + break; + case 'old': + await this.LearningPage(); + break; + case 'spelling': + await this.SpellingPage(); + break; + } + } + + async updateStatusBar() { + if (!this.statusBarEl) return; + this.statusBarEl.setText( + `${this.plugin.newCards.size} + ${Array.from(this.plugin.dueCards.values()) + .filter(card => window.moment(card.dueDate).isBefore(window.moment())).length} / ${this.plugin.dueCards.size} + ${this.plugin.allCards.size - this.plugin.enabledCards.size} = ${this.plugin.allCards.size}` + ); + } +} diff --git a/src/views/SpellingPage.ts b/src/views/SpellingPage.ts new file mode 100644 index 0000000..3e96309 --- /dev/null +++ b/src/views/SpellingPage.ts @@ -0,0 +1,182 @@ +import { Notice, MarkdownRenderer, TFile } from 'obsidian'; +import { MainView } from './MainView'; + + +export async function SpellingPage(this: MainView) { + + this.currentSpellingCard = this.pickNextSCard(); + if (!this.currentSpellingCard) return; + + this.viewContainer.empty(); + const spellingContainer = this.viewContainer.createDiv({ cls: 'openwords-spelling-container' }); + const backBtn = spellingContainer.createEl('button', { cls: 'openwords-back-btn' }); + const wordMeaningContainer = spellingContainer.createDiv({ cls: 'openwords-spelling-meaning' }); + const title = spellingContainer.createDiv({ cls: 'openwords-spelling-title', text: '=' }); + const inputContainer = spellingContainer.createDiv({ cls: 'openwords-spelling-input' }); + const feedbackContainer = spellingContainer.createDiv({ cls: 'openwords-spelling-feedback' }); + + backBtn.textContent = '返回'; + backBtn.onclick = () => { + this.page = 'type'; + this.render(); + }; + + await this.renderInput(wordMeaningContainer, inputContainer, feedbackContainer); + + // Tab键显示答案 + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Tab') { + event.preventDefault(); + if (this.currentSpellingCard) { + title.setText(`${this.currentSpellingCard.front}`); + this.hasPeeked = true; + } + } + }; + const handleKeyup = (event: KeyboardEvent) => { + if (event.key === 'Tab') { + event.preventDefault(); + title.setText('='); + } + }; + this.plugin.registerDomEvent(spellingContainer, 'keydown', handleKeydown); + this.plugin.registerDomEvent(spellingContainer, 'keyup', handleKeyup); + +} + +export function pickNextSCard(this: MainView) { + // 从易记因子最低的50%中随机选择一个单词 + this.hasPeeked = false; + this.errorCount = 0; + + const cards = Array.from(this.plugin.dueCards.values()); + if (cards.length === 0) { + new Notice("已完成所有单词!"); + this.page = 'type'; + void this.render(); + return null; + } + const sorted = cards.slice().sort((a, b) => a.efactor - b.efactor); + const half = Math.ceil(sorted.length / 2); + const pool = sorted.slice(0, half); + return pool[Math.floor(Math.random() * pool.length)]; +} + +export async function renderInput( + this: MainView, + wordMeaningContainer: HTMLElement, + inputContainer: HTMLElement, + feedbackContainer: HTMLElement, +) { + // 渲染单词的词义 + if (!this.currentSpellingCard) { return } + + const file = this.plugin.app.vault.getFileByPath(this.currentSpellingCard.path); + if (file instanceof TFile) { + const content = await this.plugin.app.vault.cachedRead(file); + const match = content.match(/##### 词义\n(?:- .*\n?)*/g); + wordMeaningContainer.empty(); + if (match) { + await MarkdownRenderer.render( + this.plugin.app, + match[0], + wordMeaningContainer, + file.path, + this.component + ); + } else { + wordMeaningContainer.setText('未找到词义'); + } + } + + // 清空输入容器并生成字母输入框 + inputContainer.empty(); + feedbackContainer.setText(''); + if (this.currentSpellingCard) { + const word = this.currentSpellingCard.front; + const inputFields: HTMLInputElement[] = []; + + for (let i = 0; i < word.length; i++) { + const inputField = inputContainer.createEl('input', { + type: 'text', + cls: 'openwords-spelling-letter', + }); + + inputFields.push(inputField); + + inputField.addEventListener('input', () => { + if (inputField.value.length === 1 && i < word.length - 1) { + inputFields[i + 1].focus(); + } + this.checkSpelling(inputFields, word, feedbackContainer, wordMeaningContainer, inputContainer); + }); + + inputField.addEventListener('keydown', (event) => { + if (event.key === 'Backspace' && inputField.value === '' && i > 0) { + inputFields[i - 1].focus(); + } + }); + } + + inputFields[0].focus(); + } +} + +export async function checkSpelling( + this: MainView, + inputFields: HTMLInputElement[], + word: string, + feedbackContainer: HTMLElement, + wordMeaningContainer: HTMLElement, + inputContainer: HTMLElement, +) { + const userInput = inputFields.map((field) => field.value).join(''); + if (userInput.length === word.length) { + if (userInput.toLowerCase() === word.toLowerCase()) { + feedbackContainer.setText('正确!'); + + if (!this.plugin.dueCards.has(word)) { + new Notice("当前单词不属于本模式范围, 评分无效并跳过"); + setTimeout(async () => { + this.currentSpellingCard = this.pickNextSCard(); + if (!this.currentSpellingCard) return; + await this.renderInput(wordMeaningContainer, inputContainer, feedbackContainer); + }, 500); + return + } + + if (this.currentSpellingCard) { + let efactor = this.currentSpellingCard.efactor; + if (this.hasPeeked) { + efactor -= 0.02; + } else if (this.errorCount === 0) { + efactor += 0.15; + } else { + efactor += 0.05; + } + if (efactor < 1.3) efactor = 1.3; + this.currentSpellingCard.efactor = efactor; + + // 同步到 frontmatter + const file = this.plugin.app.vault.getFileByPath(this.currentSpellingCard.path); + if (file instanceof TFile) { + await this.plugin.app.fileManager.processFrontMatter(file, (frontMatter) => { + frontMatter["易记因子"] = Math.round(efactor * 100); + }); + } + + new Notice(`${this.currentSpellingCard.front} \n易记因子: ${efactor.toFixed(2)} \n重复次数: ${this.currentSpellingCard.repetition} \n间隔: ${this.currentSpellingCard.interval} \n到期日: ${this.currentSpellingCard.dueDate}`); + } + setTimeout(async () => { + this.currentSpellingCard = this.pickNextSCard(); + if (!this.currentSpellingCard) return; + await this.renderInput(wordMeaningContainer, inputContainer, feedbackContainer); + }, 500); + } else { + feedbackContainer.setText('错误,请重试!'); + this.errorCount += 1; + inputFields.forEach((field) => (field.value = '')); + inputFields[0].focus(); + } + } +} diff --git a/src/views/TypePage.ts b/src/views/TypePage.ts new file mode 100644 index 0000000..880d4e1 --- /dev/null +++ b/src/views/TypePage.ts @@ -0,0 +1,49 @@ +import { Setting, Notice } from 'obsidian'; +import { MainView } from './MainView'; + +export function TypePage(this: MainView) { + this.viewContainer.empty(); + + const typeContainer = this.viewContainer.createDiv({ cls: 'openwords-type-container' }); + typeContainer.createDiv({ cls: 'openwords-type-title', text: 'OpenWords' }); + const buttonContainer = typeContainer.createDiv({ cls: 'openwords-type-button-grid' }); + + new Setting(buttonContainer) + .setName(`学习新词`) + .addButton(btn => btn + .setButtonText("开始") + .onClick(() => { + if (this.plugin.newCards.size > 0) { + this.page = 'new'; + void this.render(); + } else { + new Notice("没有新词可学了!"); + } + })); + + new Setting(buttonContainer) + .setName(`复习旧词`) + .addButton(btn => btn + .setButtonText("开始") + .onClick(() => { + if (this.plugin.dueCards.size > 0) { + this.page = 'old'; + void this.render(); + } else { + new Notice("没有需要复习的单词!"); + } + })); + + new Setting(buttonContainer) + .setName('默写单词') + .addButton(btn => btn + .setButtonText('开始') + .onClick(() => { + if (this.plugin.dueCards.size > 0) { + this.page = 'spelling'; + void this.render(); + } else { + new Notice("没有需要默写的单词!"); + } + })); +} diff --git a/styles.css b/styles.css index 2991270..b3e5d03 100644 --- a/styles.css +++ b/styles.css @@ -20,20 +20,47 @@ If your plugin does not need CSS, delete this file. border-top: 1px solid var(--background-modifier-border) } -/* 学习模式模态框 */ +/* 返回按钮 */ +.openwords-back-btn { + display: block; + margin-left: auto; + margin-right: auto; + width: 100%; + font-size: 1.05em; +} + +.openwords-view-statusbar { + display: block; + text-align: center; + margin: 32px auto 0 auto; + font-size: 1.1em; + color: var(--text-muted); + width: 100%; +} + +/* 学习模式页面 */ +.openwords-type-container { + width: 70%; /* 设置模态框宽度 */ + max-width: 700px; /* 设置模态框最大宽度 */ + margin: 0 auto; /* 居中模态框 */ +} + .openwords-type-title { text-align: center; margin-bottom: 20px; font-size: 1.5em; font-weight: bold; } -.openwords-type-modal{ - width: 650px; /* 设置模态框宽度 */ + + +/* 背单词页面 */ +.openwords-learning-container { + width: 70%; /* 设置模态框宽度 */ + max-width: 700px; /* 设置模态框最大宽度 */ + margin: 0 auto; /* 居中模态框 */ } -/* 背单词模态框 */ -.openwords-card { /* 卡片容器样式 */ - width: 500px; /* 固定宽度 */ +.openwords-learning-card { /* 卡片容器样式 */ height: 300px; /* 固定高度 */ display: flex; flex-direction: column; @@ -46,7 +73,8 @@ If your plugin does not need CSS, delete this file. text-align: center; cursor: pointer; /* 鼠标悬停时显示手型 */ } -.openwords-card:hover { + +.openwords-learning-card:hover { background-color: var(--background-modifier-hover); /* 鼠标悬停时背景变浅 */ } @@ -78,102 +106,61 @@ If your plugin does not need CSS, delete this file. user-select: text; /* 允许文本选择 */ } -.openwords-card-settings { +.openwords-learning-settings { margin-top: 20px; /* 设置容器样式 */ font-size: 1em; /* 减小字体大小 */ } -.openwords-card-settings .setting-item { +.openwords-learning-settings .setting-item { padding: 5px 10px; /* 减小内边距 */ } -/* 单词列表模态框 */ -.openwords-list{ - width: 400px; -} - -.openwords-list-title { - font-size: 1.5em; +.openwords-learning-settings button.active { + background: var(--interactive-accent); + color: #fff; font-weight: bold; - margin-bottom: 1em; - text-align: center; + box-shadow: 0 0 6px var(--interactive-accent); } -.openwords-list-filters { - display: flex; - justify-content: space-between; - margin-bottom: 1em; -} - -.openwords-list-container { - display: flex; - flex-direction: column; - gap: 0.5em; -} - -.openwords-list-filters .setting-item:first-child { - padding-top: 10px; - border-top: 1px solid var(--background-modifier-border) -} - -.openwords-list-item { /* 单词项容器样式 */ - display: flex; /* 设置为水平布局 */ - justify-content: space-between; /* 左右对齐 */ - border-bottom: 1px solid var(--background-modifier-border); - position: relative; /* 为预览容器提供定位上下文 */ -} - -.openwords-list-preview { /* Markdown 预览容器样式 */ - font-size: 1em; /* 设置预览内容字体大小 */ - line-height: 1.5; /* 设置行高 */ - overflow-y: auto; /* 添加垂直滚动条 */ - max-height: 300px; /* 设置最大高度 */ - position: absolute; /* 设置绝对定位 */ - top: 100%; /* 显示在单词项的下方 */ - left: 0; - width: 100%; - z-index: 10; /* 确保预览内容在最上层 */ - background-color: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 5px; - padding: 10px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - display: none; /* 默认隐藏 */ - user-select: text; /* 允许文本选择 */ -} - -.openwords-list-text:hover .openwords-list-preview { /* 鼠标悬停时显示预览内容 */ - display: block; /* 鼠标悬停时显示预览内容 */ -} - -.openwords-list-text { /* 单词文本样式 */ - font-size: 1.1em; - color: var(--text-normal); - cursor: pointer; -} - -/* 默写单词模态框 */ -.openwords-spelling { - width: auto; - margin: 0 auto; - padding: 20px; - border-radius: 8px; - background-color: var(--background-primary); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +/* 默写单词页面 */ +.openwords-spelling-container { + width: 70%; /* 设置模态框宽度 */ + max-width: 700px; /* 设置模态框最大宽度 */ + margin: 0 auto; /* 居中模态框 */ } .openwords-spelling-title { - font-size: 1.5em; + font-size: 2em; font-weight: bold; text-align: center; - margin-bottom: 20px; + height: 100px; + display: flex; + flex-direction: column; + justify-content: center; +} +.openwords-spelling-meaning { + width: 100%; + height: 200px; + overflow-y: auto; /* 添加垂直滚动条 */ + background-color: var(--background-secondary); /* 更柔和的背景颜色 */ + color: var(--text-muted); /* 使用正常文本颜色 */ + font-size: 1.1em; /* 稍微增大字体 */ + line-height: 1.6; /* 增加行高,提升可读性 */ + border-radius: 8px; /* 添加圆角 */ + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* 添加阴影效果 */ + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; /* 设置优雅的字体 */ + user-select: text; /* 允许文本选择 */ + margin: 20px auto; + text-align: center; } .openwords-spelling-input { display: flex; + flex-wrap: wrap; /* 支持自动换行 */ justify-content: center; gap: 10px; - margin-bottom: 20px; + margin-top: 20px; + max-width: 100%; } .openwords-spelling-letter { diff --git a/versions.json b/versions.json index 927232a..a6f9ff4 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "1.0.6": "1.8.10" + "1.0.7": "1.8.10" }