2025/8/23

This commit is contained in:
insile 2025-08-23 18:34:50 +08:00
parent 89f3491fac
commit 6b310ca202
17 changed files with 739 additions and 795 deletions

View file

@ -1,7 +1,7 @@
{
"id": "openwords",
"name": "OpenWords",
"version": "1.0.6",
"version": "1.0.7",
"minAppVersion": "1.8.10",
"description": "用于英语学习中背单词与单词管理的插件",
"author": "insile",

View file

@ -1,6 +1,6 @@
{
"name": "openwords",
"version": "1.0.6",
"version": "1.0.7",
"description": "用于英语学习中背单词与单词管理的插件",
"main": "main.js",
"scripts": {

View file

@ -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<string, CardInfo> = new Map(); // 所有单词
masterCards: Map<string, CardInfo> = 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<string, number> = {}; // 记录每个级别的总单词数
// 遍历级别,生成索引
@ -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() {

View file

@ -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();
}
}

View file

@ -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();
}));
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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,
};

View file

@ -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('缓存失败,请检查控制台日志!');

View file

@ -1,5 +1,6 @@
import {SuperMemoItem} from 'supermemo';
// 单词卡片信息
export interface CardInfo extends SuperMemoItem {
front: string;

View file

@ -1,6 +1,7 @@
import { App, TAbstractFile, TFolder, AbstractInputSuggest } from 'obsidian';
// 输入建议类, 用于文件夹路径的自动补全
export class FolderSuggest extends AbstractInputSuggest<string> {
inputEl: HTMLInputElement;
constructor(app: App, inputEl: HTMLInputElement) {

215
src/views/LearningPage.ts Normal file
View file

@ -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);
}

92
src/views/MainView.ts Normal file
View file

@ -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}`
);
}
}

182
src/views/SpellingPage.ts Normal file
View file

@ -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();
}
}
}

49
src/views/TypePage.ts Normal file
View file

@ -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("没有需要默写的单词!");
}
}));
}

View file

@ -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 {

View file

@ -1,3 +1,3 @@
{
"1.0.6": "1.8.10"
"1.0.7": "1.8.10"
}