mirror of
https://github.com/insile/OpenWords.git
synced 2026-07-22 05:48:39 +00:00
2025/8/23
This commit is contained in:
parent
89f3491fac
commit
6b310ca202
17 changed files with 739 additions and 795 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "openwords",
|
||||
"name": "OpenWords",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"minAppVersion": "1.8.10",
|
||||
"description": "用于英语学习中背单词与单词管理的插件",
|
||||
"author": "insile",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "openwords",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"description": "用于英语学习中背单词与单词管理的插件",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
155
src/main.ts
155
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<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() {
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
35
src/settings/SettingData.ts
Normal file
35
src/settings/SettingData.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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('缓存失败,请检查控制台日志!');
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import {SuperMemoItem} from 'supermemo';
|
||||
|
||||
|
||||
// 单词卡片信息
|
||||
export interface CardInfo extends SuperMemoItem {
|
||||
front: string;
|
||||
|
|
@ -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
215
src/views/LearningPage.ts
Normal 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
92
src/views/MainView.ts
Normal 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
182
src/views/SpellingPage.ts
Normal 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
49
src/views/TypePage.ts
Normal 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("没有需要默写的单词!");
|
||||
}
|
||||
}));
|
||||
}
|
||||
151
styles.css
151
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 {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"1.0.6": "1.8.10"
|
||||
"1.0.7": "1.8.10"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue