├── .npmrc ├── .eslintignore ├── src ├── events │ ├── index.ts │ └── event-manager.ts ├── commands │ ├── index.ts │ └── command-manager.ts ├── utils │ ├── index.ts │ ├── tts.ts │ ├── types.ts │ ├── color-utils.ts │ ├── trie.ts │ ├── highlight-utils.ts │ ├── pattern-matcher.ts │ └── sentence-extractor.ts ├── canvas │ ├── index.ts │ ├── layout.ts │ ├── canvas-editor.ts │ ├── mastered-group-manager.ts │ └── canvas-parser.ts ├── ui │ ├── index.ts │ ├── reading-mode-highlighter.ts │ └── pdf-highlighter.ts ├── core │ ├── index.ts │ └── mastered-service.ts ├── index.ts ├── settings.ts ├── i18n │ ├── ja.ts │ ├── zh.ts │ ├── index.ts │ ├── fr.ts │ ├── es.ts │ ├── de.ts │ └── en.ts └── services │ └── dictionary-service.ts ├── docs ├── quick_add.jpg ├── pdf_support.jpg ├── screenshot.jpg ├── ai_integration.jpg └── vocabulary_management.jpg ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .editorconfig ├── manifest.json ├── versions.json ├── .gitignore ├── tsconfig.json ├── LICENSE ├── version-bump.mjs ├── .eslintrc ├── package.json ├── esbuild.config.mjs ├── README-ZH.md ├── main.ts ├── README.md └── AGENTS.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | export { registerEvents } from './event-manager'; 2 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export { registerCommands } from './command-manager'; 2 | -------------------------------------------------------------------------------- /docs/quick_add.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatMuse/HiWords/HEAD/docs/quick_add.jpg -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: catmuse 4 | -------------------------------------------------------------------------------- /docs/pdf_support.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatMuse/HiWords/HEAD/docs/pdf_support.jpg -------------------------------------------------------------------------------- /docs/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatMuse/HiWords/HEAD/docs/screenshot.jpg -------------------------------------------------------------------------------- /docs/ai_integration.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatMuse/HiWords/HEAD/docs/ai_integration.jpg -------------------------------------------------------------------------------- /docs/vocabulary_management.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatMuse/HiWords/HEAD/docs/vocabulary_management.jpg -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 工具类和辅助函数模块 3 | */ 4 | 5 | export { Trie } from './trie'; 6 | export type { TrieMatch } from './trie'; 7 | export * from './color-utils'; 8 | export * from './types'; 9 | export * from './tts'; 10 | export * from './pattern-matcher'; 11 | -------------------------------------------------------------------------------- /src/canvas/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Canvas 相关功能模块 3 | */ 4 | 5 | export { CanvasEditor } from './canvas-editor'; 6 | export { CanvasParser } from './canvas-parser'; 7 | export { MasteredGroupManager } from './mastered-group-manager'; 8 | export { normalizeLayout, layoutGroupInner } from './layout'; 9 | -------------------------------------------------------------------------------- /src/ui/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 用户界面组件模块 3 | */ 4 | 5 | export { HiWordsSidebarView, SIDEBAR_VIEW_TYPE } from './sidebar-view'; 6 | export { DefinitionPopover } from './definition-popover'; 7 | export { AddWordModal } from './add-word-modal'; 8 | export { HiWordsSettingTab } from './settings-tab'; 9 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 核心业务逻辑模块 3 | */ 4 | 5 | export { VocabularyManager } from './vocabulary-manager'; 6 | export { MasteredService } from './mastered-service'; 7 | export { WordHighlighter, createWordHighlighterExtension, getWordUnderCursor, highlighterManager } from './word-highlighter'; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Hi Words Plugin - 主入口文件 3 | * 统一导出所有模块 4 | */ 5 | 6 | // 核心业务逻辑 7 | export * from './core'; 8 | 9 | // Canvas 相关功能 10 | export * from './canvas'; 11 | 12 | // 用户界面组件 13 | export * from './ui'; 14 | 15 | // 工具类和类型 16 | export * from './utils'; 17 | 18 | // 国际化 19 | export * from './i18n'; 20 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "hi-words", 3 | "name": "HiWords", 4 | "version": "0.5.3", 5 | "minAppVersion": "1.8.0", 6 | "description": "Effortlessly grow your vocabulary as you read, with automatic highlighting and translation of unfamiliar words.", 7 | "author": "Kai", 8 | "authorUrl": "https://github.com/CatMuse", 9 | "fundingUrl": "https://ko-fi.com/catmuse", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.15.0", 3 | "0.2.0": "0.15.0", 4 | "0.2.1": "0.15.0", 5 | "0.2.2": "0.15.0", 6 | "0.3.0": "0.15.0", 7 | "0.3.1": "0.15.0", 8 | "0.3.2": "0.15.0", 9 | "0.3.3": "0.15.0", 10 | "0.3.4": "0.15.0", 11 | "0.3.5": "0.15.0", 12 | "0.4.0": "1.8.0", 13 | "0.4.1": "1.8.0", 14 | "0.5.0": "1.8.0", 15 | "0.5.1": "1.8.0", 16 | "0.5.2": "1.8.0", 17 | "0.5.3": "1.8.0" 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "strict": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "lib": [ 14 | "DOM", 15 | "ES5", 16 | "ES6", 17 | "ES7" 18 | ] 19 | }, 20 | "include": [ 21 | "**/*.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2025 by Kai. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hi-words", 3 | "version": "0.5.3", 4 | "description": "Effortlessly grow your vocabulary as you read, with automatic highlighting and translation of unfamiliar words.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "^0.25.8", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/tts.ts: -------------------------------------------------------------------------------- 1 | // 简单的 TTS 工具:根据模板生成 URL 并播放 2 | import type HiWordsPlugin from '../../main'; 3 | 4 | let __hiw_shared_audio__: HTMLAudioElement | null = null; 5 | 6 | export function buildTtsUrl(tpl: string | undefined, word: string): string | null { 7 | if (!tpl || !word) return null; 8 | const enc = encodeURIComponent(word.trim()); 9 | return tpl.split('{{word}}').join(enc); 10 | } 11 | 12 | export async function playWordTTS(plugin: HiWordsPlugin, word: string) { 13 | const url = buildTtsUrl(plugin.settings.ttsTemplate, word); 14 | if (!url) return; 15 | 16 | try { 17 | if (!__hiw_shared_audio__) __hiw_shared_audio__ = new Audio(); 18 | const audio = __hiw_shared_audio__; 19 | audio.src = url; 20 | await audio.play(); 21 | } catch (e) { 22 | console.warn('HiWords TTS play failed:', e); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "18.x" 20 | 21 | - name: Build plugin 22 | run: | 23 | npm install 24 | npm run build 25 | 26 | - name: Create release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | run: | 30 | tag="${GITHUB_REF#refs/tags/}" 31 | 32 | gh release create "$tag" \ 33 | --title="$tag" \ 34 | --draft \ 35 | main.js manifest.json styles.css -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import type { HiWordsSettings } from './utils'; 2 | 3 | /** 4 | * 插件默认设置 5 | */ 6 | export const DEFAULT_SETTINGS: HiWordsSettings = { 7 | vocabularyBooks: [], 8 | showDefinitionOnHover: true, 9 | enableAutoHighlight: true, 10 | highlightStyle: 'underline', // 默认使用下划线样式 11 | enableMasteredFeature: true, // 默认启用已掌握功能 12 | showMasteredInSidebar: true, // 跟随 enableMasteredFeature 的值 13 | blurDefinitions: false, // 默认不启用模糊效果 14 | // 发音地址模板(用户可在设置里修改) 15 | ttsTemplate: 'https://dict.youdao.com/dictvoice?audio={{word}}&type=2', 16 | // AI 词典配置 17 | aiDictionary: { 18 | apiUrl: '', 19 | apiKey: '', 20 | model: '', 21 | prompt: 'Please provide a concise definition for the word "{{word}}" based on this context:\n\nSentence: {{sentence}}\n\nFormat:\n1) Part of speech\n2) English definition\n3) Chinese translation\n4) Example sentence (use the original sentence if appropriate)' 22 | }, 23 | // 自动布局(简化版,使用固定参数) 24 | autoLayoutEnabled: true, 25 | // 卡片尺寸设置 26 | cardWidth: 260, 27 | cardHeight: 120, 28 | // 高亮范围设置 29 | highlightMode: 'all', 30 | highlightPaths: '', 31 | // 文件节点解析模式 32 | fileNodeParseMode: 'filename-with-alias' 33 | }; 34 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | // 使用 Obsidian 官方 Canvas 类型 2 | import type { AllCanvasNodeData, CanvasData as ObsidianCanvasData } from 'obsidian/canvas'; 3 | 4 | // 导出官方类型的别名以保持向后兼容 5 | export type CanvasNode = AllCanvasNodeData; 6 | export type CanvasData = ObsidianCanvasData; 7 | 8 | // 词汇定义 9 | export interface WordDefinition { 10 | word: string; 11 | aliases?: string[]; // 单词的别名列表 12 | definition: string; 13 | source: string; // Canvas 文件路径 14 | nodeId: string; // Canvas 节点 ID 15 | color?: string; 16 | mastered?: boolean; // 是否已掌握 17 | isPattern?: boolean; // 是否为模式短语(包含 ... 占位符) 18 | patternParts?: string[]; // 模式短语的各个部分(不包含 ...) 19 | } 20 | 21 | // 生词本配置 22 | export interface VocabularyBook { 23 | path: string; // Canvas 文件路径 24 | name: string; // 显示名称 25 | enabled: boolean; // 是否启用 26 | } 27 | 28 | // 高亮样式类型 29 | export type HighlightStyle = 'underline' | 'background' | 'bold' | 'dotted' | 'wavy'; 30 | 31 | // 插件设置 32 | export interface HiWordsSettings { 33 | vocabularyBooks: VocabularyBook[]; 34 | showDefinitionOnHover: boolean; 35 | enableAutoHighlight: boolean; 36 | highlightStyle: HighlightStyle; // 高亮样式 37 | enableMasteredFeature: boolean; // 启用已掌握功能 38 | showMasteredInSidebar: boolean; // 在侧边栏显示已掌握单词 39 | blurDefinitions: boolean; // 模糊定义内容,悬停时显示 40 | // 已掌握判定模式:'group'(根据是否位于 Mastered 分组)或 'color'(根据颜色是否为绿色4) 41 | masteredDetection?: 'group' | 'color'; 42 | // 发音地址模板(如:https://dict.youdao.com/dictvoice?audio={{word}}&type=2) 43 | ttsTemplate?: string; 44 | // AI 词典配置 45 | aiDictionary?: { 46 | apiUrl: string; // AI API 地址 47 | apiKey: string; // API Key 48 | model: string; // 模型名称 49 | prompt: string; // 自定义 prompt 模板 50 | }; 51 | // 自动布局设置(简化版) 52 | autoLayoutEnabled?: boolean; // 是否启用自动布局(使用固定参数的简单网格) 53 | // 卡片尺寸设置 54 | cardWidth?: number; // 卡片宽度(默认 260) 55 | cardHeight?: number; // 卡片高度(默认 120) 56 | // 高亮范围设置 57 | highlightMode?: 'all' | 'exclude' | 'include'; // 高亮模式:全部/排除/仅指定 58 | highlightPaths?: string; // 文件路径列表(逗号分隔) 59 | // 文件节点解析模式 60 | fileNodeParseMode?: 'filename' | 'content' | 'filename-with-alias'; // 文件节点解析模式 61 | } 62 | 63 | // 词汇匹配信息 64 | export interface WordMatch { 65 | word: string; 66 | definition: WordDefinition; 67 | from: number; 68 | to: number; 69 | color: string; 70 | matchedText?: string; // 实际匹配到的文本(用于模式短语) 71 | segments?: Array<{from: number, to: number}>; // 分段高亮的位置(用于模式短语) 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/color-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 颜色工具函数 3 | */ 4 | 5 | /** 6 | * 将Canvas节点颜色映射为Obsidian CSS变量 7 | * @param canvasColor Canvas节点的颜色值(数字或颜色名称) 8 | * @param fallback 默认颜色(可选) 9 | * @returns Obsidian CSS变量字符串 10 | */ 11 | export function mapCanvasColorToCSSVar( 12 | canvasColor: string | undefined, 13 | fallback: string = 'var(--color-accent)' 14 | ): string { 15 | if (!canvasColor) return fallback; 16 | 17 | // Canvas颜色映射表 18 | const colorMap: { [key: string]: string } = { 19 | // 数字映射(Canvas常用) 20 | '1': 'var(--color-red)', 21 | '2': 'var(--color-orange)', 22 | '3': 'var(--color-yellow)', 23 | '4': 'var(--color-green)', 24 | '5': 'var(--color-cyan)', 25 | '6': 'var(--color-purple)', 26 | 27 | // 颜色名称映射 28 | 'red': 'var(--color-red)', 29 | 'orange': 'var(--color-orange)', 30 | 'yellow': 'var(--color-yellow)', 31 | 'green': 'var(--color-green)', 32 | 'cyan': 'var(--color-cyan)', 33 | 'blue': 'var(--color-blue)', 34 | 'purple': 'var(--color-purple)', 35 | 'pink': 'var(--color-pink)', 36 | 37 | // 额外的颜色支持 38 | 'gray': 'var(--color-base-60)', 39 | 'grey': 'var(--color-base-60)', 40 | 'white': 'var(--color-base-00)', 41 | 'black': 'var(--color-base-100)' 42 | }; 43 | 44 | // 如果找到映射,返回CSS变量;否则返回原始颜色值 45 | return colorMap[canvasColor.toLowerCase()] || canvasColor; 46 | } 47 | 48 | /** 49 | * 获取颜色的淡化版本(用于背景色) 50 | * @param cssVar CSS变量字符串 51 | * @param opacity 透明度 (0-1) 52 | * @returns 带透明度的颜色字符串 53 | */ 54 | export function getColorWithOpacity(cssVar: string, opacity: number = 0.1): string { 55 | // 如果是CSS变量,使用color-mix函数来创建透明度效果 56 | if (cssVar.startsWith('var(--color-')) { 57 | // 使用现代CSS的color-mix函数 58 | return `color-mix(in srgb, ${cssVar} ${opacity * 100}%, transparent)`; 59 | } 60 | 61 | // 对于其他颜色值,也使用color-mix 62 | if (cssVar.startsWith('#') || cssVar.startsWith('rgb') || cssVar.startsWith('hsl')) { 63 | return `color-mix(in srgb, ${cssVar} ${opacity * 100}%, transparent)`; 64 | } 65 | 66 | // 如果都不匹配,直接返回原值 67 | return cssVar; 68 | } 69 | 70 | /** 71 | * 检查是否为有效的Canvas颜色 72 | * @param color 颜色值 73 | * @returns 是否为有效颜色 74 | */ 75 | export function isValidCanvasColor(color: string | undefined): boolean { 76 | if (!color) return false; 77 | 78 | const validColors = ['1', '2', '3', '4', '5', '6', 'red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink']; 79 | return validColors.includes(color.toLowerCase()); 80 | } 81 | -------------------------------------------------------------------------------- /src/commands/command-manager.ts: -------------------------------------------------------------------------------- 1 | import { Editor, Notice, MarkdownView } from 'obsidian'; 2 | import type HiWordsPlugin from '../../main'; 3 | import { t } from '../i18n'; 4 | import { extractSentenceFromEditorMultiline, extractSentenceFromSelection } from '../utils/sentence-extractor'; 5 | 6 | /** 7 | * 注册所有插件命令 8 | * @param plugin HiWords 插件实例 9 | */ 10 | export function registerCommands(plugin: HiWordsPlugin) { 11 | // 刷新生词本命令 12 | plugin.addCommand({ 13 | id: 'refresh-vocabulary', 14 | name: t('commands.refresh_vocabulary'), 15 | callback: async () => { 16 | await plugin.vocabularyManager.loadAllVocabularyBooks(); 17 | plugin.refreshHighlighter(); 18 | new Notice(t('notices.vocabulary_refreshed')); 19 | } 20 | }); 21 | 22 | // 打开生词列表侧边栏命令 23 | plugin.addCommand({ 24 | id: 'open-vocabulary-sidebar', 25 | name: t('commands.show_sidebar'), 26 | callback: () => { 27 | plugin.activateSidebarView(); 28 | } 29 | }); 30 | 31 | // 添加选中单词到生词本命令(智能适配所有视图模式) 32 | plugin.addCommand({ 33 | id: 'add-selected-word', 34 | name: t('commands.add_selected_word'), 35 | callback: () => { 36 | let word = ''; 37 | let sentence = ''; 38 | 39 | // 尝试获取当前活动的编辑器 40 | const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView); 41 | const editor = activeView?.editor; 42 | const viewMode = activeView?.getMode(); 43 | 44 | // 检查是否在编辑模式(Live Preview 或 Source Mode) 45 | // Live Preview 和 Source Mode 都返回 'source',阅读模式返回 'preview' 46 | const isEditMode = editor && viewMode === 'source'; 47 | 48 | if (isEditMode) { 49 | // 编辑模式:使用 Editor API,句子提取更准确(支持跨行) 50 | word = editor.getSelection().trim(); 51 | sentence = extractSentenceFromEditorMultiline(editor); 52 | } else { 53 | // 阅读模式/PDF 视图:使用 window.getSelection() 54 | const selection = window.getSelection(); 55 | word = selection?.toString().trim() || ''; 56 | 57 | // 备用方案:如果 selection.toString() 为空,尝试从 range 获取 58 | if (!word && selection && selection.rangeCount > 0) { 59 | word = selection.getRangeAt(0).toString().trim(); 60 | } 61 | 62 | sentence = extractSentenceFromSelection(selection); 63 | } 64 | 65 | // 打开模态框(有选中文本时预填充,无选中时可手动输入) 66 | plugin.addOrEditWord(word, sentence); 67 | } 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /src/canvas/layout.ts: -------------------------------------------------------------------------------- 1 | import { CanvasData, CanvasNode, HiWordsSettings } from '../utils'; 2 | import { CanvasParser } from './canvas-parser'; 3 | 4 | // 固定布局参数 5 | const BASE_X = 50; 6 | const BASE_Y = 50; 7 | const DEFAULT_CARD_WIDTH = 260; 8 | const DEFAULT_CARD_HEIGHT = 120; 9 | const GAP = 20; 10 | const COLUMNS = 3; 11 | const GROUP_PADDING = 24; 12 | const GROUP_GAP = 12; 13 | const GROUP_COLUMNS = 2; 14 | 15 | /** 16 | * 简化的布局算法:使用固定参数的网格布局 17 | * - 左侧区域:3列固定网格(布局所有非分组节点:text 和 file) 18 | * - Mastered 分组内:2列固定网格 19 | * - 无复杂计算,位置可预测 20 | */ 21 | export function normalizeLayout( 22 | canvasData: CanvasData, 23 | settings: HiWordsSettings, 24 | parser: CanvasParser 25 | ) { 26 | if (!settings.autoLayoutEnabled) return; 27 | 28 | // 从设置中读取卡片尺寸,如果未设置则使用默认值 29 | const CARD_WIDTH = settings.cardWidth ?? DEFAULT_CARD_WIDTH; 30 | const CARD_HEIGHT = settings.cardHeight ?? DEFAULT_CARD_HEIGHT; 31 | 32 | const masteredGroup = canvasData.nodes.find( 33 | (n) => n.type === 'group' && n.label === 'Mastered' 34 | ); 35 | 36 | // 收集需要布局的节点(不在 Mastered 分组内的 text 和 file 节点) 37 | const movableNodes = canvasData.nodes.filter((n) => { 38 | if (n.type === 'group') return false; // 排除分组 39 | if (masteredGroup && parser.isNodeInGroup(n, masteredGroup)) return false; 40 | return true; 41 | }); 42 | 43 | if (movableNodes.length === 0) return; 44 | 45 | // 简单网格布局 46 | for (let i = 0; i < movableNodes.length; i++) { 47 | const node = movableNodes[i]; 48 | const col = i % COLUMNS; 49 | const row = Math.floor(i / COLUMNS); 50 | 51 | node.x = BASE_X + col * (CARD_WIDTH + GAP); 52 | node.y = BASE_Y + row * (CARD_HEIGHT + GAP); 53 | node.width = CARD_WIDTH; 54 | node.height = CARD_HEIGHT; 55 | } 56 | } 57 | 58 | /** 59 | * 分组内部布局:简单的固定列网格 60 | */ 61 | export function layoutGroupInner( 62 | canvasData: CanvasData, 63 | group: CanvasNode, 64 | settings: HiWordsSettings, 65 | parser: CanvasParser 66 | ) { 67 | // 从设置中读取卡片尺寸,如果未设置则使用默认值 68 | const CARD_WIDTH = settings.cardWidth ?? DEFAULT_CARD_WIDTH; 69 | const CARD_HEIGHT = settings.cardHeight ?? DEFAULT_CARD_HEIGHT; 70 | 71 | const members = canvasData.nodes.filter( 72 | (n) => n.type !== 'group' && parser.isNodeInGroup(n, group) 73 | ); 74 | 75 | if (members.length === 0) return; 76 | 77 | // 简单网格布局 78 | for (let i = 0; i < members.length; i++) { 79 | const node = members[i]; 80 | const col = i % GROUP_COLUMNS; 81 | const row = Math.floor(i / GROUP_COLUMNS); 82 | 83 | node.x = group.x + GROUP_PADDING + col * (CARD_WIDTH + GROUP_GAP); 84 | node.y = group.y + GROUP_PADDING + row * (CARD_HEIGHT + GROUP_GAP); 85 | node.width = CARD_WIDTH; 86 | node.height = CARD_HEIGHT; 87 | } 88 | 89 | // 根据内容调整分组尺寸 90 | const rows = Math.ceil(members.length / GROUP_COLUMNS); 91 | const minWidth = GROUP_PADDING * 2 + GROUP_COLUMNS * CARD_WIDTH + (GROUP_COLUMNS - 1) * GROUP_GAP; 92 | const minHeight = GROUP_PADDING * 2 + rows * CARD_HEIGHT + (rows - 1) * GROUP_GAP; 93 | 94 | group.width = Math.max(group.width, minWidth); 95 | group.height = Math.max(group.height, minHeight); 96 | } 97 | -------------------------------------------------------------------------------- /src/utils/trie.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 前缀树(Trie)数据结构实现 3 | * 用于高效地匹配多个单词 4 | */ 5 | export class Trie { 6 | private root: TrieNode; 7 | 8 | constructor() { 9 | this.root = new TrieNode(); 10 | } 11 | 12 | /** 13 | * 向前缀树中添加单词 14 | * @param word 要添加的单词 15 | * @param payload 与单词关联的数据 16 | */ 17 | addWord(word: string, payload: any): void { 18 | let node = this.root; 19 | const lowerWord = word.toLowerCase(); 20 | 21 | for (const char of lowerWord) { 22 | if (!node.children.has(char)) { 23 | node.children.set(char, new TrieNode()); 24 | } 25 | node = node.children.get(char)!; 26 | } 27 | 28 | node.isEndOfWord = true; 29 | node.payload = payload; 30 | node.word = word; // 保存原始单词形式 31 | } 32 | 33 | /** 34 | * 在文本中查找所有匹配的单词 35 | * @param text 要搜索的文本 36 | * @returns 匹配结果数组,每个结果包含单词、位置和关联数据 37 | */ 38 | findAllMatches(text: string): TrieMatch[] { 39 | const matches: TrieMatch[] = []; 40 | const lowerText = text.toLowerCase(); 41 | 42 | // 对文本中的每个位置尝试匹配 43 | for (let i = 0; i < lowerText.length; i++) { 44 | let node = this.root; 45 | let j = i; 46 | 47 | // 尝试从当前位置匹配单词 48 | while (j < lowerText.length && node.children.has(lowerText[j])) { 49 | node = node.children.get(lowerText[j])!; 50 | j++; 51 | 52 | // 如果到达单词结尾,添加匹配 53 | if (node.isEndOfWord) { 54 | // 检查单词边界 55 | const isWordBoundaryStart = i === 0 || !isAlphaNumeric(lowerText[i - 1]); 56 | const isWordBoundaryEnd = j === lowerText.length || !isAlphaNumeric(lowerText[j]); 57 | 58 | if (isWordBoundaryStart && isWordBoundaryEnd) { 59 | matches.push({ 60 | word: node.word || lowerText.substring(i, j), 61 | from: i, 62 | to: j, 63 | payload: node.payload 64 | }); 65 | } 66 | } 67 | } 68 | } 69 | 70 | return matches; 71 | } 72 | 73 | /** 74 | * 清空前缀树 75 | */ 76 | clear(): void { 77 | this.root = new TrieNode(); 78 | } 79 | } 80 | 81 | /** 82 | * 前缀树节点 83 | */ 84 | class TrieNode { 85 | children: Map; 86 | isEndOfWord: boolean; 87 | payload: any; 88 | word: string | null; 89 | 90 | constructor() { 91 | this.children = new Map(); 92 | this.isEndOfWord = false; 93 | this.payload = null; 94 | this.word = null; 95 | } 96 | } 97 | 98 | /** 99 | * 前缀树匹配结果 100 | */ 101 | export interface TrieMatch { 102 | word: string; 103 | from: number; 104 | to: number; 105 | payload: any; 106 | } 107 | 108 | /** 109 | * 检查字符是否为字母或数字 110 | * 支持英文、中文、日语、韩语等字符 111 | */ 112 | function isAlphaNumeric(char: string): boolean { 113 | return /[a-z0-9\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/iu.test(char); 114 | } 115 | -------------------------------------------------------------------------------- /src/utils/highlight-utils.ts: -------------------------------------------------------------------------------- 1 | import type { HiWordsSettings } from './index'; 2 | import { Trie } from './trie'; 3 | import type { VocabularyManager } from '../core'; 4 | 5 | // ==================== 文件高亮判断 ==================== 6 | 7 | /** 8 | * 检查文件是否应该被高亮 9 | * @param filePath 文件路径 10 | * @param settings 插件设置 11 | * @returns 是否应该高亮该文件 12 | */ 13 | export function shouldHighlightFile(filePath: string, settings: HiWordsSettings): boolean { 14 | const mode = settings.highlightMode || 'all'; 15 | 16 | // 模式1:全部高亮 17 | if (mode === 'all') { 18 | return true; 19 | } 20 | 21 | // 解析路径列表(逗号分隔,去除空格) 22 | const pathsStr = settings.highlightPaths || ''; 23 | const paths = pathsStr 24 | .split(',') 25 | .map(p => p.trim()) 26 | .filter(p => p.length > 0); 27 | 28 | // 如果路径列表为空 29 | if (paths.length === 0) { 30 | // 排除模式下空列表=全部高亮,包含模式下空列表=全不高亮 31 | return mode === 'exclude'; 32 | } 33 | 34 | // 标准化当前文件路径 35 | const normalizedFile = filePath.replace(/^\/+|\/+$/g, ''); 36 | 37 | // 检查文件路径是否匹配任何规则 38 | const isMatched = paths.some(path => { 39 | const normalizedPath = path.replace(/^\/+|\/+$/g, ''); 40 | return normalizedFile === normalizedPath || 41 | normalizedFile.startsWith(normalizedPath + '/'); 42 | }); 43 | 44 | // 模式2:排除模式 - 匹配到则不高亮 45 | if (mode === 'exclude') { 46 | return !isMatched; 47 | } 48 | 49 | // 模式3:仅指定路径 - 匹配到才高亮 50 | if (mode === 'include') { 51 | return isMatched; 52 | } 53 | 54 | return true; 55 | } 56 | 57 | // ==================== DOM 操作和视口检测 ==================== 58 | 59 | /** 60 | * 检查元素是否在视口中可见 61 | */ 62 | export function isElementVisible(element: HTMLElement): boolean { 63 | const rect = element.getBoundingClientRect(); 64 | const windowHeight = window.innerHeight || document.documentElement.clientHeight; 65 | const windowWidth = window.innerWidth || document.documentElement.clientWidth; 66 | 67 | // 元素至少有一部分在视口内 68 | return ( 69 | rect.bottom > 0 && 70 | rect.right > 0 && 71 | rect.top < windowHeight && 72 | rect.left < windowWidth 73 | ); 74 | } 75 | 76 | /** 77 | * 检查容器是否在主编辑器中(排除侧边栏、弹出框等) 78 | */ 79 | export function isInMainEditor(element: HTMLElement): boolean { 80 | return !element.closest('.workspace-leaf-content[data-type="hover-editor"]') && 81 | !element.closest('.workspace-leaf-content[data-type="file-explorer"]') && 82 | !element.closest('.workspace-leaf-content[data-type="outline"]') && 83 | !element.closest('.workspace-leaf-content[data-type="backlink"]') && 84 | !element.closest('.workspace-leaf-content[data-type="tag"]') && 85 | !element.closest('.workspace-leaf-content[data-type="search"]') && 86 | !element.closest('.hover-popover') && 87 | !element.closest('.popover') && 88 | !element.closest('.suggestion-container') && 89 | !element.closest('.modal') && 90 | !element.closest('.workspace-split.mod-right-split') && 91 | !element.closest('.workspace-split.mod-left-split'); 92 | } 93 | 94 | /** 95 | * 清除元素中的所有高亮标记 96 | */ 97 | export function clearHighlights(element: HTMLElement): void { 98 | const highlights = element.querySelectorAll('.hi-words-highlight'); 99 | highlights.forEach(highlight => { 100 | // 将高亮元素替换为纯文本 101 | const textNode = document.createTextNode(highlight.textContent || ''); 102 | highlight.parentNode?.replaceChild(textNode, highlight); 103 | }); 104 | 105 | // 合并相邻的文本节点 106 | element.normalize(); 107 | } 108 | 109 | // ==================== Trie 构建 ==================== 110 | 111 | /** 112 | * 构建包含所有单词的 Trie 树 113 | */ 114 | export function buildTrieFromVocabulary(vocabularyManager: VocabularyManager): Trie { 115 | const trie = new Trie(); 116 | const words = vocabularyManager.getAllWordsForHighlight(); 117 | for (const w of words) { 118 | const def = vocabularyManager.getDefinition(w); 119 | if (def) trie.addWord(w, def); 120 | } 121 | return trie; 122 | } 123 | -------------------------------------------------------------------------------- /README-ZH.md: -------------------------------------------------------------------------------- 1 |
2 |

HiWords - 智能单词本管理插件

3 | GitHub Downloads (all assets, all releases) 4 | GitHub release (latest by date) 5 | GitHub last commit 6 | GitHub issues 7 | GitHub stars 8 |
9 | 10 | --- 11 | 12 | 简体中文 | [English](./README.md) 13 | 14 | 一款智能的 Obsidian 插件,将您的阅读转化为沉浸式的词汇学习体验。HiWords 自动高亮显示来自您自定义单词本的生词,悬停即可查看释义,让您在阅读中轻松掌握新单词。 15 | 16 | ![Screenshot](docs/screenshot.jpg) 17 | 18 | --- 19 | 20 | ## 📚 基于 Canvas 的单词本管理 21 | 22 | 使用 Obsidian 强大的 Canvas 功能管理您的单词本。您可以在 Canvas 上自由拖放排列单词卡片,为不同主题、语言或学习目标创建多个独立的单词本,并使用节点颜色按难度、主题或掌握程度对单词进行分类。所有对单词本的更改都会自动同步并反映在您的阅读高亮中。 23 | 24 | ![单词管理](docs/vocabulary_management.jpg) 25 | 26 | --- 27 | 28 | ## 🎯 智能高亮系统 29 | 30 | HiWords 智能地在笔记中高亮显示单词本中的单词,让您轻松发现和复习正在学习的单词。阅读时即时识别并高亮单词本中的词汇,高亮颜色与 Canvas 节点颜色保持一致,您还可以灵活选择在所有文件、特定文件夹中高亮或排除某些路径。基于 CodeMirror 6 构建,即使处理大型文档也能流畅运行。 31 | 32 | 不仅支持编辑模式,还完美支持 Markdown 阅读模式和 PDF 文件的高亮显示,让您在任何阅读场景下都能获得一致的学习体验。 33 | 34 | ![支持PDF](docs/pdf_support.jpg) 35 | 36 | --- 37 | 38 | ## 💡 悬停即显释义 39 | 40 | 只需将鼠标悬停在任何高亮单词上,即可即时查看支持 Markdown 格式的详细释义,无需离开当前文档。您可以直接在弹窗中标记已掌握单词,点击单词即可听取读音(支持自定义 TTS 服务,默认为英文发音),弹窗界面还会无缝适配您的 Obsidian 主题,提供一致的视觉体验。 41 | 42 | --- 43 | 44 | ## 🤖 AI 智能释义 45 | 46 | 配置您喜欢的 AI 服务(支持 OpenAI、Anthropic 等兼容格式),让 AI 根据上下文自动填充相关释义。您可以使用 `{{word}}` 和 `{{sentence}}` 变量自定义提示词模板,在添加新单词时快速生成 AI 释义,帮助您更好地理解单词在特定语境中的含义。 47 | 48 | ![AI Integration](docs/ai_integration.jpg) 49 | 50 | --- 51 | 52 | ## 📋 侧边栏词汇视图 53 | 54 | 通过快捷命令可打开侧边栏,追踪您的词汇学习,一目了然地查看当前文档中出现的所有单词。点击任何单词即可发音,颜色保持与 Canvas 节点颜色一致。您可以切换已掌握单词的可见性以专注于主动学习,列表会随着文档编辑或切换实时自动更新。 55 | 56 | --- 57 | 58 | ## ⚡ 快速单词管理 59 | 60 | 选择任何文本并右键点击即可快速添加到单词本,或使用 `Ctrl/Cmd+P` 通过命令面板添加选中的单词。插件会智能检测单词是否已存在并自动切换到编辑模式,添加时还会捕获周围句子以提供更好的上下文。支持高效管理不同单词本中的多个单词,让词汇管理更加便捷。 61 | 62 | ![快速添加](docs/quick_add.jpg) 63 | 64 | --- 65 | 66 | ## 🚀 快速开始 67 | 68 | ### 安装插件 69 | 70 | **从 Obsidian 社区插件安装(推荐)** 71 | 72 | 1. 打开 Obsidian 设置 → 社区插件 73 | 2. 搜索 "HiWords" 74 | 3. 点击安装,然后启用 75 | 76 | ### 创建您的第一个单词本 77 | 78 | 1. **创建 Canvas 文件** 79 | 80 | - 在文件浏览器中右键 → 新建 Canvas 81 | - 命名(例如:`英语单词本.canvas`) 82 | 83 | 2. **添加单词卡片** 84 | 85 | - 创建文本节点,格式如下: 86 | 87 | ``` 88 | 89 | serendipity 90 | *serendipitous, serendipitously* 91 | 92 | **n.** 意外发现珍奇事物的能力;机缘巧合 93 | 94 | **例句:** The discovery of penicillin was a fortunate serendipity. 95 | 青霉素的发现是一次幸运的机缘巧合。 96 | 97 | ``` 98 | 99 | 3. **使用颜色组织** 100 | 101 | - 点击节点可以设置卡片颜色 102 | - 使用颜色按难度、主题或掌握程度分类 103 | 104 | 4. **关联到 HiWords** 105 | 106 | - 打开 HiWords 设置 107 | - 添加您的 Canvas 文件作为单词本 108 | - 开始阅读,观察单词自动高亮! 109 | 110 | > **Tips**:您可以直接将文件拖拽进 Canvas 中,HiWords 会自动解析文件内容并添加到单词本中。可在 HiWords 设置中配置文件节点模式,选择仅文件名或包含别名。 111 | 112 | --- 113 | 114 | ## ⚙️ 配置选项 115 | 116 | ### 高亮设置 117 | 118 | - **启用自动高亮**:切换自动单词高亮 119 | - **高亮样式**:选择高亮显示样式,支持背景高亮、下划线、加粗等多种样式 120 | - **高亮范围**:所有文件(默认)、仅特定文件夹、排除特定文件夹 121 | 122 | ### 悬停弹窗设置 123 | 124 | - **悬停显示**:启用/禁用释义弹窗 125 | - **模糊释义**:模糊释义直到悬停(用于主动回忆练习) 126 | - **TTS 模板**:自定义发音服务 URL 127 | 128 | ### AI 助手设置 129 | 130 | - **API URL**:您的 AI 服务端点 131 | - **API Key**:AI 服务的认证密钥 132 | - **模型**:要使用的 AI 模型(例如 gpt-4o-mini) 133 | - **自定义提示词**:使用 `{{word}}` 和 `{{sentence}}` 占位符设计您的提示词 134 | 135 | ### Canvas 设置 136 | 137 | - **自动布局**:自动排列新的单词卡片 138 | - **卡片尺寸**:设置单词卡片的默认宽度和高度 139 | - **文件节点模式**:选择如何解析文件节点(仅文件名或包含别名) 140 | 141 | ### 掌握追踪 142 | 143 | - **启用掌握功能**:追踪您已掌握的单词 144 | - **在侧边栏显示已掌握**:在侧边栏视图中显示或隐藏已掌握的单词 145 | 146 | --- 147 | 148 | ## 🎯 使用技巧 149 | 150 | ### 组织单词本 151 | 152 | - **按语言**:为不同语言创建独立的单词本 153 | - **按主题**:按学科组织单词(商务、学术、日常等) 154 | - **按来源**:将不同书籍或课程的单词分开 155 | - **按难度**:使用颜色标记初级、中级和高级单词 156 | 157 | ### 有效的学习工作流 158 | 159 | 1. **自然阅读** - 让 HiWords 自动高亮单词 160 | 2. **悬停复习** - 在不中断流程的情况下查看释义 161 | 3. **标记已掌握** - 在学习过程中追踪进度 162 | 4. **添加新词** - 右键添加或快捷命令添加遇到的生词 163 | 5. **使用 AI 辅助** - 生成上下文相关的释义以更好地理解 164 | 165 | --- 166 | 167 | ## 📝 命令列表 168 | 169 | 通过 `Ctrl/Cmd+P` 访问这些命令: 170 | 171 | - **刷新单词本** - 重新加载所有单词本 172 | - **显示单词侧边栏** - 打开侧边栏视图 173 | - **添加选中单词** - 将选中文本添加到单词本 174 | 175 | --- 176 | 177 | ## 🔒 隐私与安全 178 | 179 | HiWords 注重隐私保护:所有单词数据都存储在您的本地 vault 中,插件默认完全离线工作且无任何遥测。可选的 AI 词典功能(默认禁用)仅在您手动使用自动填充按钮时,才会将单词和句子直接发送到您配置的 AI 服务商。 180 | 181 | --- 182 | 183 | ## 🤝 支持 184 | 185 | 如果您觉得 HiWords 有帮助,请考虑支持其开发: 186 | 187 | - [☕ 在 Ko-fi 上请我喝咖啡](https://ko-fi.com/catmuse) 188 | - [⭐ 在 GitHub 上给项目加星](https://github.com/CatMuse/HiWords) 189 | - [🐛 报告问题或建议功能](https://github.com/CatMuse/HiWords/issues) 190 | 191 | --- 192 | 193 | ## 📄 许可证 194 | 195 | MIT License - 可自由使用和修改。 196 | 197 | --- 198 | 199 | **Made with ❤️ by [CatMuse](https://github.com/CatMuse)** 200 | -------------------------------------------------------------------------------- /src/events/event-manager.ts: -------------------------------------------------------------------------------- 1 | import { Editor, Notice, TFile } from 'obsidian'; 2 | import type HiWordsPlugin from '../../main'; 3 | import { t } from '../i18n'; 4 | import { extractSentenceFromEditorMultiline } from '../utils/sentence-extractor'; 5 | 6 | /** 7 | * 注册所有插件事件监听器 8 | * @param plugin HiWords 插件实例 9 | */ 10 | export function registerEvents(plugin: HiWordsPlugin) { 11 | // 记录当前正在编辑的Canvas文件 12 | const modifiedCanvasFiles = new Set(); 13 | // 记录当前活动的 Canvas 文件 14 | let activeCanvasFile: string | null = null; 15 | 16 | // 监听文件变化 17 | plugin.registerEvent( 18 | plugin.app.vault.on('modify', (file) => { 19 | if (file instanceof TFile && file.extension === 'canvas') { 20 | // 检查是否是生词本文件 21 | const isVocabBook = plugin.settings.vocabularyBooks.some(book => book.path === file.path); 22 | if (isVocabBook) { 23 | // 只记录文件路径,不立即解析 24 | modifiedCanvasFiles.add(file.path); 25 | } 26 | } 27 | }) 28 | ); 29 | 30 | // 监听活动文件变化 31 | plugin.registerEvent( 32 | plugin.app.workspace.on('active-leaf-change', async (leaf) => { 33 | // 获取当前活动文件 34 | const activeFile = plugin.app.workspace.getActiveFile(); 35 | 36 | // 如果之前有活动的Canvas文件,且已经变化,并且现在切换到了其他文件 37 | // 说明用户已经编辑完成并切换了焦点,此时解析该文件 38 | if (activeCanvasFile && 39 | modifiedCanvasFiles.has(activeCanvasFile) && 40 | (!activeFile || activeFile.path !== activeCanvasFile)) { 41 | 42 | await plugin.vocabularyManager.reloadVocabularyBook(activeCanvasFile); 43 | plugin.refreshHighlighter(); 44 | 45 | // 从待解析列表中移除 46 | modifiedCanvasFiles.delete(activeCanvasFile); 47 | } 48 | 49 | // 更新当前活动的Canvas文件 50 | if (activeFile && activeFile.extension === 'canvas') { 51 | activeCanvasFile = activeFile.path; 52 | } else { 53 | activeCanvasFile = null; 54 | 55 | // 如果切换到非Canvas文件,处理所有待解析的文件 56 | if (modifiedCanvasFiles.size > 0) { 57 | // 创建一个副本并清空原集合 58 | const filesToProcess = Array.from(modifiedCanvasFiles); 59 | modifiedCanvasFiles.clear(); 60 | 61 | // 处理所有待解析的文件 62 | for (const filePath of filesToProcess) { 63 | await plugin.vocabularyManager.reloadVocabularyBook(filePath); 64 | } 65 | 66 | // 刷新高亮 67 | plugin.refreshHighlighter(); 68 | } else { 69 | // 当切换文件时,可能需要更新高亮 70 | setTimeout(() => plugin.refreshHighlighter(), 100); 71 | } 72 | } 73 | }) 74 | ); 75 | 76 | // 监听文件重命名/移动 77 | plugin.registerEvent( 78 | plugin.app.vault.on('rename', async (file, oldPath) => { 79 | if (file instanceof TFile && file.extension === 'canvas') { 80 | // 检查旧路径是否在单词本列表中 81 | const bookIndex = plugin.settings.vocabularyBooks.findIndex(book => book.path === oldPath); 82 | if (bookIndex !== -1) { 83 | // 更新为新路径 84 | plugin.settings.vocabularyBooks[bookIndex].path = file.path; 85 | // 更新名称(使用新的文件名) 86 | plugin.settings.vocabularyBooks[bookIndex].name = file.basename; 87 | await plugin.saveSettings(); 88 | 89 | // 删除旧路径的数据,避免重复计数 90 | plugin.vocabularyManager.removeBookData(oldPath); 91 | 92 | // 重新加载该单词本(使用新路径) 93 | await plugin.vocabularyManager.reloadVocabularyBook(file.path); 94 | plugin.refreshHighlighter(); 95 | 96 | new Notice(t('notices.book_path_updated').replace('{0}', file.basename)); 97 | } 98 | } 99 | }) 100 | ); 101 | 102 | // 注册编辑器右键菜单 103 | plugin.registerEvent( 104 | plugin.app.workspace.on('editor-menu', (menu, editor: Editor) => { 105 | const selection = editor.getSelection(); 106 | if (selection && selection.trim()) { 107 | const word = selection.trim(); 108 | // 检查单词是否已存在 109 | const exists = plugin.vocabularyManager.hasWord(word); 110 | 111 | menu.addItem((item) => { 112 | // 根据单词是否存在显示不同的菜单项文本 113 | const titleKey = exists ? 'commands.edit_word' : 'commands.add_word'; 114 | 115 | item 116 | .setTitle(t(titleKey)) 117 | .onClick(() => { 118 | // 提取句子(支持跨行) 119 | const sentence = extractSentenceFromEditorMultiline(editor); 120 | plugin.addOrEditWord(word, sentence); 121 | }); 122 | }); 123 | } 124 | }) 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /src/ui/reading-mode-highlighter.ts: -------------------------------------------------------------------------------- 1 | import type { HiWordsSettings } from '../utils'; 2 | import { Trie, mapCanvasColorToCSSVar } from '../utils'; 3 | import type { VocabularyManager } from '../core'; 4 | import { isElementVisible, buildTrieFromVocabulary, clearHighlights, isInMainEditor } from '../utils/highlight-utils'; 5 | 6 | /** 7 | * 在阅读模式注册 Markdown 后处理器,高亮匹配的词汇。 8 | * 通过从 VocabularyManager 构建 Trie,遍历渲染后的 DOM 文本节点并包裹 span.hi-words-highlight。 9 | */ 10 | export function registerReadingModeHighlighter(plugin: { 11 | settings: HiWordsSettings; 12 | vocabularyManager: VocabularyManager; 13 | shouldHighlightFile: (filePath: string) => boolean; 14 | registerMarkdownPostProcessor: ( 15 | processor: (el: HTMLElement, ctx: unknown) => void 16 | ) => void; 17 | }): void { 18 | // 存储处理函数的引用,供外部调用 19 | let processorFn: ((el: HTMLElement, trie: Trie) => void) | null = null; 20 | 21 | const EXCLUDE_SELECTOR = [ 22 | 'pre', 23 | 'code', 24 | 'a', 25 | 'button', 26 | 'input', 27 | 'textarea', 28 | 'select', 29 | '.math', 30 | '.cm-inline-code', 31 | '.internal-embed', 32 | '.file-embed', 33 | '.hi-words-tooltip', // 排除 tooltip 内容 34 | ].join(','); 35 | 36 | const processElement = (root: HTMLElement, trie: Trie) => { 37 | const walker = document.createTreeWalker( 38 | root, 39 | NodeFilter.SHOW_TEXT, 40 | { 41 | acceptNode: (node: Node) => { 42 | // 仅处理可见文本节点,跳过排除元素与已高亮区域 43 | const maybeParent = (node as any).parentElement as HTMLElement | null | undefined; 44 | const parent = maybeParent ?? null; 45 | if (!parent) return NodeFilter.FILTER_REJECT; 46 | if (parent.closest(EXCLUDE_SELECTOR)) return NodeFilter.FILTER_REJECT; 47 | if (parent.closest('.hi-words-highlight')) return NodeFilter.FILTER_REJECT; 48 | if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; 49 | return NodeFilter.FILTER_ACCEPT; 50 | }, 51 | } as any 52 | ); 53 | 54 | const highlightStyle = plugin.settings.highlightStyle || 'underline'; 55 | 56 | const textNodes: Text[] = []; 57 | let current: Node | null = walker.nextNode(); 58 | while (current) { 59 | textNodes.push(current as Text); 60 | current = walker.nextNode(); 61 | } 62 | 63 | for (const textNode of textNodes) { 64 | const text = textNode.nodeValue || ''; 65 | if (!text) continue; 66 | 67 | const matches = trie.findAllMatches(text) as Array<{ 68 | from: number; 69 | to: number; 70 | word: string; 71 | payload: any; 72 | }>; 73 | if (!matches || matches.length === 0) continue; 74 | 75 | // 左到右、优先更长的非重叠匹配 76 | matches.sort((a, b) => a.from - b.from || (b.to - b.from) - (a.to - a.from)); 77 | const filtered: typeof matches = []; 78 | let end = 0; 79 | for (const m of matches) { 80 | if (m.from >= end) { 81 | filtered.push(m); 82 | end = m.to; 83 | } 84 | } 85 | if (filtered.length === 0) continue; 86 | 87 | const frag = document.createDocumentFragment(); 88 | let last = 0; 89 | for (const m of filtered) { 90 | if (m.from > last) frag.appendChild(document.createTextNode(text.slice(last, m.from))); 91 | const def = m.payload; 92 | const color = mapCanvasColorToCSSVar(def?.color, 'var(--color-base-60)'); 93 | const span = document.createElement('span'); 94 | span.className = 'hi-words-highlight'; 95 | span.setAttribute('data-word', m.word); 96 | if (def?.definition) span.setAttribute('data-definition', def.definition); 97 | if (color) span.setAttribute('data-color', color); 98 | span.setAttribute('data-style', highlightStyle); 99 | if (color) span.setAttribute('style', `--word-highlight-color: ${color}`); 100 | span.textContent = text.slice(m.from, m.to); 101 | frag.appendChild(span); 102 | last = m.to; 103 | } 104 | if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); 105 | 106 | if (textNode.parentNode) textNode.parentNode.replaceChild(frag, textNode); 107 | } 108 | }; 109 | 110 | plugin.registerMarkdownPostProcessor((el, ctx) => { 111 | try { 112 | if (!plugin.settings.enableAutoHighlight) return; 113 | 114 | // 检查当前文件是否应该被高亮 115 | const filePath = (ctx as any)?.sourcePath; 116 | if (filePath && !plugin.shouldHighlightFile(filePath)) { 117 | return; 118 | } 119 | 120 | // 检查是否在主编辑器的阅读模式中(排除侧边栏、悬停预览等其他容器) 121 | if (!isInMainEditor(el)) return; 122 | 123 | const trie = buildTrieFromVocabulary(plugin.vocabularyManager); 124 | // 保存处理函数引用 125 | processorFn = processElement; 126 | processElement(el, trie); 127 | } catch (e) { 128 | console.error('阅读模式高亮处理失败:', e); 129 | } 130 | }); 131 | 132 | // 导出刷新函数到插件实例 133 | (plugin as any)._refreshReadingModeHighlighter = () => { 134 | refreshVisibleReadingMode(plugin, processorFn); 135 | }; 136 | } 137 | 138 | /** 139 | * 刷新可见区域的阅读模式高亮 140 | */ 141 | function refreshVisibleReadingMode( 142 | plugin: { 143 | settings: HiWordsSettings; 144 | vocabularyManager: VocabularyManager; 145 | shouldHighlightFile: (filePath: string) => boolean; 146 | }, 147 | processElement: ((el: HTMLElement, trie: Trie) => void) | null 148 | ): void { 149 | if (!plugin.settings.enableAutoHighlight || !processElement) return; 150 | 151 | try { 152 | // 重新构建 Trie 153 | const trie = buildTrieFromVocabulary(plugin.vocabularyManager); 154 | 155 | // 查找所有阅读模式的容器 156 | const readingContainers = document.querySelectorAll('.markdown-preview-view .markdown-preview-sizer'); 157 | 158 | readingContainers.forEach(container => { 159 | const htmlContainer = container as HTMLElement; 160 | 161 | // 检查容器是否在主编辑器中(排除侧边栏等) 162 | if (!isInMainEditor(htmlContainer)) return; 163 | 164 | // 只处理可见的容器 165 | if (!isElementVisible(htmlContainer)) return; 166 | 167 | // 清除现有高亮 168 | clearHighlights(htmlContainer); 169 | 170 | // 重新高亮 171 | processElement(htmlContainer, trie); 172 | }); 173 | } catch (error) { 174 | console.error('刷新阅读模式高亮失败:', error); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/utils/pattern-matcher.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 模式短语匹配工具 3 | * 支持使用 ... 占位符的跨单词短语匹配 4 | */ 5 | 6 | /** 7 | * 解析短语,检测是否包含占位符 8 | */ 9 | export function parsePhrase(phrase: string): { 10 | isPattern: boolean; 11 | parts: string[]; 12 | original: string; 13 | } { 14 | const trimmed = phrase.trim(); 15 | 16 | if (trimmed.includes('...')) { 17 | // 拆分短语,过滤空字符串 18 | const parts = trimmed.split('...').map(p => p.trim()).filter(p => p.length > 0); 19 | return { 20 | isPattern: true, 21 | parts: parts, 22 | original: trimmed 23 | }; 24 | } 25 | 26 | return { 27 | isPattern: false, 28 | parts: [trimmed], 29 | original: trimmed 30 | }; 31 | } 32 | 33 | /** 34 | * 转义正则表达式特殊字符 35 | */ 36 | function escapeRegExp(string: string): string { 37 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 38 | } 39 | 40 | /** 41 | * 构建模式短语的匹配正则表达式 42 | * @param parts 短语的各个固定部分 43 | * @returns 正则表达式 44 | */ 45 | export function buildPatternRegex(parts: string[]): RegExp { 46 | if (parts.length === 0) return /(?!)/; // 永远不匹配 47 | if (parts.length === 1) { 48 | // 单个部分,使用边界匹配 49 | const escaped = escapeRegExp(parts[0]); 50 | return new RegExp(`\\b${escaped}\\b`, 'gi'); 51 | } 52 | 53 | // 多个部分,使用占位符连接 54 | // 句子边界:不包含 . , ! ? ; : \n 等标点符号 55 | const sentenceBoundary = '[^.,!?;:\\n]*?'; 56 | const escapedParts = parts.map(p => escapeRegExp(p)); 57 | 58 | // 构建模式:part1 [不跨句] part2 [不跨句] part3 ... 59 | const pattern = escapedParts.join(sentenceBoundary); 60 | 61 | return new RegExp(pattern, 'gi'); 62 | } 63 | 64 | /** 65 | * 在文本中查找模式短语的所有匹配 66 | * @param text 要搜索的文本 67 | * @param parts 短语的各个固定部分 68 | * @param offset 文本在文档中的偏移量 69 | * @returns 匹配结果数组,包含匹配位置和各段位置 70 | */ 71 | export function findPatternMatches( 72 | text: string, 73 | parts: string[], 74 | offset: number = 0 75 | ): Array<{ 76 | from: number; 77 | to: number; 78 | matchedText: string; 79 | segments: Array<{from: number, to: number}>; 80 | }> { 81 | const matches: Array<{ 82 | from: number; 83 | to: number; 84 | matchedText: string; 85 | segments: Array<{from: number, to: number}>; 86 | }> = []; 87 | 88 | if (parts.length === 0) return matches; 89 | 90 | // 如果只有一个部分,直接匹配 91 | if (parts.length === 1) { 92 | const regex = new RegExp(`\\b${escapeRegExp(parts[0])}\\b`, 'gi'); 93 | let match; 94 | while ((match = regex.exec(text)) !== null) { 95 | matches.push({ 96 | from: offset + match.index, 97 | to: offset + match.index + match[0].length, 98 | matchedText: match[0], 99 | segments: [{ 100 | from: offset + match.index, 101 | to: offset + match.index + match[0].length 102 | }] 103 | }); 104 | } 105 | return matches; 106 | } 107 | 108 | // 多部分匹配:手动查找各部分 109 | const lowerText = text.toLowerCase(); 110 | const lowerParts = parts.map(p => p.toLowerCase()); 111 | 112 | // 从文本开始位置查找第一部分 113 | let searchStart = 0; 114 | while (searchStart < text.length) { 115 | const firstPartIndex = lowerText.indexOf(lowerParts[0], searchStart); 116 | if (firstPartIndex === -1) break; 117 | 118 | // 检查第一部分的单词边界 119 | if (!isWordBoundary(text, firstPartIndex, firstPartIndex + parts[0].length)) { 120 | searchStart = firstPartIndex + 1; 121 | continue; 122 | } 123 | 124 | // 尝试匹配后续部分 125 | const segments: Array<{from: number, to: number}> = []; 126 | segments.push({ 127 | from: offset + firstPartIndex, 128 | to: offset + firstPartIndex + parts[0].length 129 | }); 130 | 131 | let currentPos = firstPartIndex + parts[0].length; 132 | let allPartsMatched = true; 133 | 134 | for (let i = 1; i < parts.length; i++) { 135 | // 在当前位置到下一个句子边界之间查找下一部分 136 | const nextBoundary = findNextSentenceBoundary(text, currentPos); 137 | const searchText = text.substring(currentPos, nextBoundary); 138 | const lowerSearchText = searchText.toLowerCase(); 139 | 140 | const partIndex = lowerSearchText.indexOf(lowerParts[i]); 141 | if (partIndex === -1) { 142 | allPartsMatched = false; 143 | break; 144 | } 145 | 146 | const absolutePartIndex = currentPos + partIndex; 147 | 148 | // 检查单词边界 149 | if (!isWordBoundary(text, absolutePartIndex, absolutePartIndex + parts[i].length)) { 150 | allPartsMatched = false; 151 | break; 152 | } 153 | 154 | segments.push({ 155 | from: offset + absolutePartIndex, 156 | to: offset + absolutePartIndex + parts[i].length 157 | }); 158 | 159 | currentPos = absolutePartIndex + parts[i].length; 160 | } 161 | 162 | if (allPartsMatched) { 163 | const matchStart = firstPartIndex; 164 | const matchEnd = currentPos; 165 | matches.push({ 166 | from: offset + matchStart, 167 | to: offset + matchEnd, 168 | matchedText: text.substring(matchStart, matchEnd), 169 | segments: segments 170 | }); 171 | } 172 | 173 | searchStart = firstPartIndex + 1; 174 | } 175 | 176 | return matches; 177 | } 178 | 179 | /** 180 | * 查找下一个句子边界的位置 181 | */ 182 | function findNextSentenceBoundary(text: string, startPos: number): number { 183 | const boundaries = ['.', ',', '!', '?', ';', ':', '\n']; 184 | let minPos = text.length; 185 | 186 | for (const boundary of boundaries) { 187 | const pos = text.indexOf(boundary, startPos); 188 | if (pos !== -1 && pos < minPos) { 189 | minPos = pos; 190 | } 191 | } 192 | 193 | return minPos; 194 | } 195 | 196 | /** 197 | * 检查单词边界 198 | * 支持英文、中文、日语、韩语等字符 199 | */ 200 | function isWordBoundary(text: string, start: number, end: number): boolean { 201 | const before = start > 0 ? text[start - 1] : ' '; 202 | const after = end < text.length ? text[end] : ' '; 203 | 204 | // 检查前后字符是否为单词字符 205 | const isWordChar = (char: string) => { 206 | return /[a-z0-9\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/iu.test(char); 207 | }; 208 | 209 | return !isWordChar(before) && !isWordChar(after); 210 | } 211 | -------------------------------------------------------------------------------- /src/utils/sentence-extractor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 句子提取工具 3 | */ 4 | 5 | /** 6 | * 从文本中提取包含指定位置的句子 7 | * @param text 完整文本 8 | * @param position 光标位置(相对于文本开头的字符索引) 9 | * @returns 提取的句子 10 | */ 11 | export function extractSentence(text: string, position: number): string { 12 | if (!text || position < 0 || position > text.length) { 13 | return ''; 14 | } 15 | 16 | // 句子结束标记 17 | const sentenceEnders = /[.!?。!?\n]/; 18 | 19 | // 向前查找句子开始位置 20 | let start = position; 21 | while (start > 0) { 22 | const char = text[start - 1]; 23 | if (sentenceEnders.test(char)) { 24 | break; 25 | } 26 | start--; 27 | } 28 | 29 | // 向后查找句子结束位置 30 | let end = position; 31 | while (end < text.length) { 32 | const char = text[end]; 33 | if (sentenceEnders.test(char)) { 34 | // 包含结束标点 35 | end++; 36 | break; 37 | } 38 | end++; 39 | } 40 | 41 | // 提取句子并清理空白 42 | const sentence = text.substring(start, end).trim(); 43 | 44 | return sentence; 45 | } 46 | 47 | /** 48 | * 从编辑器中提取选中文本所在的句子 49 | * @param editor Obsidian 编辑器实例 50 | * @returns 提取的句子 51 | */ 52 | export function extractSentenceFromEditor(editor: any): string { 53 | try { 54 | const cursor = editor.getCursor(); 55 | const line = editor.getLine(cursor.line); 56 | const ch = cursor.ch; 57 | 58 | // 使用 extractSentence 从当前行提取句子 59 | return extractSentence(line, ch); 60 | } catch (error) { 61 | console.error('Failed to extract sentence from editor:', error); 62 | return ''; 63 | } 64 | } 65 | 66 | /** 67 | * 从多行文本中提取句子 68 | * @param editor Obsidian 编辑器实例 69 | * @returns 提取的句子(可能跨行) 70 | */ 71 | export function extractSentenceFromEditorMultiline(editor: any): string { 72 | try { 73 | const cursor = editor.getCursor(); 74 | const doc = editor.getValue(); 75 | 76 | // 计算光标在整个文档中的位置 77 | let position = 0; 78 | for (let i = 0; i < cursor.line; i++) { 79 | position += editor.getLine(i).length + 1; // +1 for newline 80 | } 81 | position += cursor.ch; 82 | 83 | // 从整个文档中提取句子 84 | return extractSentence(doc, position); 85 | } catch (error) { 86 | console.error('Failed to extract sentence from editor (multiline):', error); 87 | return ''; 88 | } 89 | } 90 | 91 | /** 92 | * 从 DOM Selection 中提取句子 93 | * 适用于阅读模式和 PDF 视图 94 | * @param selection window.getSelection() 返回的选区对象 95 | * @returns 提取的句子 96 | */ 97 | export function extractSentenceFromSelection(selection: Selection | null): string { 98 | if (!selection || selection.rangeCount === 0) { 99 | return ''; 100 | } 101 | 102 | try { 103 | const range = selection.getRangeAt(0); 104 | const selectedText = selection.toString().trim(); 105 | 106 | if (!selectedText) { 107 | return ''; 108 | } 109 | 110 | // 获取选区的起始节点 111 | let startNode = range.startContainer; 112 | 113 | // 如果是元素节点,尝试获取其中的文本节点 114 | if (startNode.nodeType === Node.ELEMENT_NODE) { 115 | const textNode = startNode.childNodes[range.startOffset]; 116 | if (textNode && textNode.nodeType === Node.TEXT_NODE) { 117 | startNode = textNode; 118 | } 119 | } 120 | 121 | // 查找合适的段落容器 122 | const paragraphContainer = findParagraphContainer(startNode); 123 | if (!paragraphContainer) { 124 | return ''; 125 | } 126 | 127 | // 获取段落文本 128 | const paragraphText = paragraphContainer.textContent || ''; 129 | 130 | // 计算选中文本在段落中的实际位置 131 | const actualPosition = calculateTextPosition(paragraphContainer, startNode, range.startOffset, selectedText); 132 | if (actualPosition === -1) { 133 | return ''; 134 | } 135 | 136 | // 从选中文本的中间位置提取句子 137 | const middlePosition = actualPosition + Math.floor(selectedText.length / 2); 138 | return extractSentence(paragraphText, middlePosition); 139 | } catch (error) { 140 | console.error('Failed to extract sentence from selection:', error); 141 | return ''; 142 | } 143 | } 144 | 145 | /** 146 | * 查找合适的段落容器 147 | * @param startNode 起始节点 148 | * @returns 段落容器元素 149 | */ 150 | function findParagraphContainer(startNode: Node): HTMLElement | null { 151 | let currentNode: Node | null = startNode; 152 | 153 | // 如果是文本节点,获取其父元素 154 | if (currentNode.nodeType === Node.TEXT_NODE) { 155 | currentNode = currentNode.parentNode; 156 | } 157 | 158 | let paragraphContainer: HTMLElement | null = null; 159 | 160 | while (currentNode && currentNode.nodeType !== Node.DOCUMENT_NODE) { 161 | if (currentNode.nodeType === Node.ELEMENT_NODE) { 162 | const element = currentNode as HTMLElement; 163 | const tagName = element.tagName?.toLowerCase(); 164 | 165 | // 阅读模式:查找段落级元素 166 | if (tagName === 'p' || tagName === 'li' || tagName === 'blockquote') { 167 | return element; 168 | } 169 | 170 | // 查找合适大小的 div 容器 171 | if (tagName === 'div') { 172 | const textLength = element.textContent?.length || 0; 173 | if (textLength > 0 && textLength < 5000) { 174 | paragraphContainer = element; 175 | } 176 | } 177 | 178 | // PDF 模式:查找 textLayer 容器 179 | if (element.classList.contains('textLayer')) { 180 | return element; 181 | } 182 | 183 | // PDF 模式:从 page 容器中查找 textLayer 184 | if (element.classList.contains('page') && element.closest('.pdf-container')) { 185 | const textLayer = element.querySelector('.textLayer'); 186 | if (textLayer) { 187 | return textLayer as HTMLElement; 188 | } 189 | } 190 | } 191 | currentNode = currentNode.parentNode; 192 | } 193 | 194 | return paragraphContainer; 195 | } 196 | 197 | /** 198 | * 计算选中文本在段落中的实际位置 199 | * @param container 段落容器 200 | * @param startNode 起始节点 201 | * @param startOffset 起始偏移量 202 | * @param selectedText 选中的文本 203 | * @returns 文本位置,-1 表示未找到 204 | */ 205 | function calculateTextPosition( 206 | container: HTMLElement, 207 | startNode: Node, 208 | startOffset: number, 209 | selectedText: string 210 | ): number { 211 | // 使用 TreeWalker 遍历文本节点来计算精确位置 212 | const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null); 213 | 214 | let currentOffset = 0; 215 | let node: Node | null; 216 | 217 | while ((node = walker.nextNode())) { 218 | if (node === startNode || node.contains(startNode)) { 219 | return currentOffset + startOffset; 220 | } 221 | currentOffset += node.textContent?.length || 0; 222 | } 223 | 224 | // 后备方案:使用 indexOf 查找 225 | const paragraphText = container.textContent || ''; 226 | return paragraphText.indexOf(selectedText); 227 | } 228 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin, WorkspaceLeaf } from 'obsidian'; 2 | import { Extension } from '@codemirror/state'; 3 | // 使用新的模块化导入 4 | import { HiWordsSettings } from './src/utils'; 5 | import { DEFAULT_SETTINGS } from './src/settings'; 6 | import { registerReadingModeHighlighter } from './src/ui/reading-mode-highlighter'; 7 | import { registerPDFHighlighter, cleanupPDFHighlighter } from './src/ui/pdf-highlighter'; 8 | import { VocabularyManager, MasteredService, createWordHighlighterExtension, highlighterManager } from './src/core'; 9 | import { DefinitionPopover, HiWordsSettingTab, HiWordsSidebarView, SIDEBAR_VIEW_TYPE, AddWordModal } from './src/ui'; 10 | import { i18n } from './src/i18n'; 11 | import { registerCommands } from './src/commands'; 12 | import { registerEvents } from './src/events'; 13 | import { shouldHighlightFile } from './src/utils/highlight-utils'; 14 | 15 | export default class HiWordsPlugin extends Plugin { 16 | settings!: HiWordsSettings; 17 | vocabularyManager!: VocabularyManager; 18 | definitionPopover!: DefinitionPopover; 19 | masteredService!: MasteredService; 20 | editorExtensions: Extension[] = []; 21 | private isSidebarInitialized = false; 22 | 23 | async onload() { 24 | // 加载设置(快速完成) 25 | await this.loadSettings(); 26 | 27 | // 初始化国际化模块 28 | i18n.setApp(this.app); 29 | 30 | // 初始化管理器(不加载数据) 31 | this.vocabularyManager = new VocabularyManager(this.app, this.settings); 32 | 33 | // 初始化已掌握服务 34 | this.masteredService = new MasteredService(this, this.vocabularyManager); 35 | 36 | // 初始化定义弹出框(作为 Component 需要加载) 37 | this.definitionPopover = new DefinitionPopover(this); 38 | this.addChild(this.definitionPopover); 39 | this.definitionPopover.setVocabularyManager(this.vocabularyManager); 40 | this.definitionPopover.setMasteredService(this.masteredService); 41 | 42 | // 注册侧边栏视图 43 | this.registerView( 44 | SIDEBAR_VIEW_TYPE, 45 | (leaf) => new HiWordsSidebarView(leaf, this) 46 | ); 47 | 48 | // 注册编辑器扩展 49 | this.setupEditorExtensions(); 50 | 51 | // 注册命令 52 | this.registerCommands(); 53 | 54 | // 注册事件 55 | this.registerEvents(); 56 | 57 | // 注册阅读模式(Markdown)后处理器,实现阅读模式高亮 58 | registerReadingModeHighlighter(this); 59 | 60 | // 注册 PDF 高亮功能 61 | registerPDFHighlighter(this); 62 | 63 | // 添加设置页面 64 | this.addSettingTab(new HiWordsSettingTab(this.app, this)); 65 | 66 | // 初始化侧边栏 67 | this.initializeSidebar(); 68 | 69 | // 延迟加载生词本(在布局准备好后) 70 | // 这样可以加快插件启动速度,避免阻塞 Obsidian 启动 71 | this.app.workspace.onLayoutReady(async () => { 72 | await this.vocabularyManager.loadAllVocabularyBooks(); 73 | this.refreshHighlighter(); 74 | }); 75 | } 76 | 77 | /** 78 | * 设置编辑器扩展 79 | * 注意: 扩展始终注册,但会在 WordHighlighter 内部检查 enableAutoHighlight 设置 80 | */ 81 | private setupEditorExtensions() { 82 | // 始终注册扩展,让 WordHighlighter 内部根据设置决定是否高亮 83 | const extension = createWordHighlighterExtension( 84 | this.vocabularyManager, 85 | (filePath) => shouldHighlightFile(filePath, this.settings) 86 | ); 87 | this.editorExtensions = [extension]; 88 | this.registerEditorExtension(this.editorExtensions); 89 | } 90 | 91 | /** 92 | * 注册命令(委托给命令管理器) 93 | */ 94 | private registerCommands() { 95 | registerCommands(this); 96 | } 97 | 98 | /** 99 | * 注册事件(委托给事件管理器) 100 | */ 101 | private registerEvents() { 102 | registerEvents(this); 103 | } 104 | 105 | /** 106 | * 检查文件是否应该被高亮(包装方法) 107 | */ 108 | shouldHighlightFile(filePath: string): boolean { 109 | return shouldHighlightFile(filePath, this.settings); 110 | } 111 | 112 | /** 113 | * 刷新高亮器 114 | */ 115 | refreshHighlighter() { 116 | // 始终刷新高亮器,让 WordHighlighter 内部根据设置决定是否高亮 117 | highlighterManager.refreshAll(); 118 | 119 | // 刷新阅读模式(只更新可见区域) 120 | if ((this as any)._refreshReadingModeHighlighter) { 121 | (this as any)._refreshReadingModeHighlighter(); 122 | } 123 | 124 | // 刷新 PDF 模式(只更新可见区域) 125 | if ((this as any)._refreshPDFHighlighter) { 126 | (this as any)._refreshPDFHighlighter(); 127 | } 128 | 129 | // 刷新侧边栏视图(通过 API 获取) 130 | const leaves = this.app.workspace.getLeavesOfType(SIDEBAR_VIEW_TYPE); 131 | leaves.forEach(leaf => { 132 | if (leaf.view instanceof HiWordsSidebarView) { 133 | leaf.view.refresh(); 134 | } 135 | }); 136 | } 137 | 138 | /** 139 | * 初始化侧边栏 140 | */ 141 | private async initializeSidebar() { 142 | if (this.isSidebarInitialized) return; 143 | 144 | // 只注册视图,不自动打开 145 | this.app.workspace.onLayoutReady(() => { 146 | this.isSidebarInitialized = true; 147 | }); 148 | } 149 | 150 | /** 151 | * 激活侧边栏视图 152 | */ 153 | async activateSidebarView() { 154 | const { workspace } = this.app; 155 | 156 | let leaf: WorkspaceLeaf | null = null; 157 | const leaves = workspace.getLeavesOfType(SIDEBAR_VIEW_TYPE); 158 | 159 | if (leaves.length > 0) { 160 | // 如果已经存在,就激活它 161 | leaf = leaves[0]; 162 | } else { 163 | // 否则创建新的侧边栏视图 164 | leaf = workspace.getRightLeaf(false); 165 | if (leaf) { 166 | await leaf.setViewState({ type: SIDEBAR_VIEW_TYPE, active: true }); 167 | } 168 | } 169 | 170 | if (leaf) { 171 | workspace.revealLeaf(leaf); 172 | } 173 | } 174 | 175 | /** 176 | * 加载设置 177 | */ 178 | async loadSettings() { 179 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 180 | } 181 | 182 | /** 183 | * 保存设置 184 | */ 185 | async saveSettings() { 186 | await this.saveData(this.settings); 187 | this.vocabularyManager.updateSettings(this.settings); 188 | this.masteredService.updateSettings(); 189 | } 190 | 191 | /** 192 | * 添加或编辑单词 193 | * 检查单词是否已存在,如果存在则打开编辑模式,否则打开添加模式 194 | * @param word 要添加或编辑的单词 195 | * @param sentence 单词所在的句子(可选) 196 | */ 197 | addOrEditWord(word: string, sentence: string = '') { 198 | // 检查单词是否已存在 199 | const exists = this.vocabularyManager.hasWord(word); 200 | 201 | if (exists) { 202 | // 如果单词已存在,打开编辑模式 203 | new AddWordModal(this.app, this, word, sentence, true).open(); 204 | } else { 205 | // 如果单词不存在,打开添加模式 206 | new AddWordModal(this.app, this, word, sentence).open(); 207 | } 208 | } 209 | 210 | /** 211 | * 卸载插件 212 | */ 213 | onunload() { 214 | // definitionPopover 作为子组件会自动卸载 215 | this.vocabularyManager.clear(); 216 | // 清理增量更新相关资源 217 | if (this.vocabularyManager.destroy) { 218 | this.vocabularyManager.destroy(); 219 | } 220 | // 清理全局高亮器管理器 221 | highlighterManager.clear(); 222 | // 清理 PDF 高亮器资源 223 | cleanupPDFHighlighter(this); 224 | } 225 | } -------------------------------------------------------------------------------- /src/i18n/ja.ts: -------------------------------------------------------------------------------- 1 | // 日本語 (Japanese) language pack 2 | 3 | export default { 4 | // General 5 | plugin_name: "HiWords", 6 | 7 | // Settings 8 | settings: { 9 | vocabulary_books: "単語帳", 10 | add_vocabulary_book: "単語帳を追加", 11 | remove_vocabulary_book: "削除", 12 | show_definition_on_hover: "ホバーで定義を表示", 13 | show_definition_on_hover_desc: "強調表示された単語にマウスを乗せると定義を表示します", 14 | enable_auto_highlight: "自動ハイライトを有効化", 15 | enable_auto_highlight_desc: "読書中に単語帳の単語を自動的にハイライトします", 16 | highlight_style: "ハイライトスタイル", 17 | highlight_style_desc: "テキスト中の単語のハイライト方法を選択", 18 | style_underline: "下線", 19 | style_background: "背景", 20 | style_bold: "太字", 21 | style_dotted: "点線の下線", 22 | style_wavy: "波線の下線", 23 | save_settings: "設定を保存", 24 | no_vocabulary_books: "単語帳がまだありません。Canvas ファイルを単語帳として追加してください。", 25 | path: "パス", 26 | reload_book: "この単語帳を再読み込み", 27 | statistics: "統計", 28 | total_books: "単語帳の合計: {0}", 29 | enabled_books: "有効な単語帳: {0}", 30 | total_words: "単語の合計: {0}", 31 | enable_mastered_feature: "習得済み機能を有効化", 32 | enable_mastered_feature_desc: "習得済みの単語をマークしてハイライトを停止。サイドバーにグループ表示", 33 | blur_definitions: "コンテンツをぼかす", 34 | blur_definitions_desc: "デフォルトでぼかし、ホバー時に表示。学習時に答えを見る前に思い出すのに最適", 35 | tts_template: "TTSテンプレート", 36 | tts_template_desc: "{{word}}をプレースホルダーとして使用、例:https://dict.youdao.com/dictvoice?audio={{word}}&type=2", 37 | ai_dictionary: "AIアシスタント", 38 | ai_api_url: "API URL", 39 | ai_api_url_desc: "APIエンドポイント(自動検出:OpenAI、Claude、Gemini)", 40 | ai_api_key: "APIキー", 41 | ai_api_key_desc: "あなたのAI APIキー", 42 | ai_model: "モデル", 43 | ai_model_desc: "AIモデル名(例:gpt-4o-mini、deepseek-chat)", 44 | ai_prompt: "カスタムプロンプト", 45 | ai_prompt_desc: "{{word}}と{{sentence}}をプレースホルダーとして使用。AIはこのプロンプトを使用して定義を生成します", 46 | // Auto layout 47 | auto_layout: "キャンバス自動レイアウト", 48 | enable_auto_layout: "自動レイアウトを有効化", 49 | enable_auto_layout_desc: "追加/更新/削除およびグループ変更後にレイアウトを自動正規化", 50 | card_size: "カードサイズ", 51 | card_size_desc: "デフォルトカードサイズ(幅 × 高さ、デフォルト:260 × 120)", 52 | grid_gaps: "グリッド間隔", 53 | grid_gaps_desc: "左グリッドの水平/垂直間隔", 54 | left_padding: "左余白と最小 X", 55 | left_padding_desc: "Mastered グループからの左余白と左エリアの最小 X", 56 | columns_auto: "自動カラム", 57 | columns_auto_desc: "カラム数を自動決定(最大カラム数で制限)", 58 | columns: "固定カラム / 最大カラム", 59 | columns_desc: "固定カラムと最大カラムを指定(自動がオフの時に有効)", 60 | group_inner_layout: "グループ内部レイアウト", 61 | group_inner_layout_desc: "グループ内ノードの内側余白、間隔、カラム設定", 62 | highlight_scope: "ハイライト範囲", 63 | highlight_mode: "ハイライトモード", 64 | highlight_mode_desc: "単語ハイライトの範囲モードを選択", 65 | mode_all: "すべてのファイル", 66 | mode_exclude: "指定パスを除外", 67 | mode_include: "指定パスのみ", 68 | highlight_paths: "ファイルパス", 69 | highlight_paths_desc: "複数のパスをカンマで区切り、フォルダパスをサポート(サブフォルダを含む)", 70 | highlight_paths_placeholder: "例:Archive, Templates, Private/Diary", 71 | // File node parse mode 72 | file_node_parse_mode: "ファイルノード解析モード", 73 | file_node_parse_mode_desc: "Canvas 内のファイルカードの解析方法を選択", 74 | mode_filename: "ファイル名のみ", 75 | mode_content: "ファイル内容のみ", 76 | mode_filename_with_alias: "ファイル名を単語、内容をエイリアスとして", 77 | }, 78 | 79 | // Sidebar 80 | sidebar: { 81 | title: "HiWords", 82 | empty_state: "単語が見つかりません。単語帳に追加するとここに表示されます。", 83 | source_prefix: "出典: ", 84 | found: "見つかった", 85 | words: "単語", 86 | no_definition: "定義はありません。", 87 | vocabulary_book: "単語", 88 | mastered: "習得済み", 89 | no_learning_words: "学習する単語はありません", 90 | no_mastered_words: "習得済みの単語はありません", 91 | }, 92 | 93 | // Commands 94 | commands: { 95 | refresh_vocabulary: "単語帳を更新", 96 | add_word: "HiWords: 単語を追加", 97 | edit_word: "HiWords: 単語を編集", 98 | show_sidebar: "HiWords サイドバーを表示", 99 | add_selected_word: "単語を追加", 100 | }, 101 | 102 | // Notices 103 | notices: { 104 | vocabulary_refreshed: "単語帳を更新しました", 105 | word_added: "単語を単語帳に追加しました", 106 | word_exists: "単語はすでに単語帳に存在します", 107 | error_adding_word: "単語の追加中にエラーが発生しました", 108 | select_book_required: "単語帳を選択してください", 109 | adding_word: "単語を追加中...", 110 | updating_word: "単語を更新中...", 111 | word_added_success: "単語 \"{0}\" を追加しました", 112 | word_updated_success: "単語 \"{0}\" を更新しました", 113 | add_word_failed: "単語を追加できませんでした。単語帳ファイルを確認してください", 114 | update_word_failed: "単語を更新できませんでした。単語帳ファイルを確認してください", 115 | error_processing_word: "単語の処理中にエラーが発生しました", 116 | no_canvas_files: "Canvas ファイルが見つかりません", 117 | book_already_exists: "この単語帳は既に存在します", 118 | invalid_canvas_file: "無効な Canvas ファイルです", 119 | book_added: "単語帳を追加しました: {0}", 120 | book_reloaded: "単語帳を再読み込みしました: {0}", 121 | book_removed: "単語帳を削除しました: {0}", 122 | deleting_word: "単語を削除中...", 123 | word_deleted: "単語を削除しました", 124 | delete_word_failed: "単語を削除できませんでした。ファイルを確認してください", 125 | error_deleting_word: "削除中にエラーが発生しました", 126 | mastered_feature_disabled: "習得済み機能は有効になっていません", 127 | update_word_status_failed: "単語のステータス更新に失敗しました", 128 | move_to_mastered_group_failed: "習得済みグループへの移動に失敗しました", 129 | word_marked_as_mastered: "\"{0}\" を習得済みに設定しました", 130 | mark_mastered_failed: "マークに失敗しました。もう一度試してください", 131 | remove_from_mastered_group_failed: "習得済みグループからの削除に失敗しました", 132 | word_unmarked_as_mastered: "\"{0}\" のマスター済みマークを解除しました", 133 | no_text_selected: "追加する単語を選択してください", 134 | word_required: "単語を入力してください", 135 | unmark_mastered_failed: "マーク解除に失敗しました。もう一度試してください", 136 | batch_marked_success: "{0}個の単語を習得済みとしてマークしました", 137 | book_path_updated: "単語帳のパスが更新されました: {0}", 138 | new_notice: "新しい通知", 139 | }, 140 | 141 | // Modals 142 | modals: { 143 | auto_fill_definition: "AI定義", 144 | word_label: "単語", 145 | word_placeholder: "追加する単語を入力...", 146 | definition_label: "定義", 147 | book_label: "単語帳", 148 | select_book: "単語帳を選択", 149 | color_label: "カードの色", 150 | color_gray: "グレー", 151 | color_red: "赤", 152 | color_orange: "オレンジ", 153 | color_yellow: "黄色", 154 | color_green: "緑", 155 | color_blue: "青", 156 | color_purple: "紫", 157 | aliases_label: "別名(任意、カンマ区切り)", 158 | aliases_placeholder: "例: doing, done, did", 159 | definition_placeholder: "定義を入力...", 160 | add_button: "追加", 161 | save_button: "保存", 162 | cancel_button: "キャンセル", 163 | select_canvas_file: "単語帳ファイルを選択", 164 | delete_confirmation: "単語 \"{0}\" を削除してもよろしいですか?\nこの操作は元に戻せません。", 165 | }, 166 | // 共通アクションラベル 167 | actions: { 168 | expand: "展開", 169 | collapse: "折りたたむ", 170 | mark_mastered: "習得済みにする", 171 | unmark_mastered: "習得済みを解除", 172 | }, 173 | // AI辞書エラー 174 | ai_errors: { 175 | word_empty: "単語を入力してください", 176 | api_key_not_configured: "APIキーが設定されていません。プラグイン設定で設定してください", 177 | invalid_response: "APIが無効な応答形式を返しました", 178 | api_key_invalid: "❌ APIキーが無効または期限切れです。プラグイン設定を確認してください", 179 | rate_limit: "⏱️ APIレート制限を超えました。後でもう一度お試しください", 180 | server_error: "🔧 APIサービスが一時的に利用できません。後でもう一度お試しください", 181 | network_error: "🌐 ネットワーク接続に失敗しました。ネットワーク設定を確認してください", 182 | request_failed: "AI辞書リクエストが失敗しました", 183 | }, 184 | } 185 | -------------------------------------------------------------------------------- /src/i18n/zh.ts: -------------------------------------------------------------------------------- 1 | // 中文语言包 2 | 3 | export default { 4 | // 通用 5 | plugin_name: "HiWords", 6 | 7 | // 设置 8 | settings: { 9 | vocabulary_books: "单词本", 10 | add_vocabulary_book: "添加单词本", 11 | remove_vocabulary_book: "移除", 12 | show_definition_on_hover: "悬停显示释义", 13 | show_definition_on_hover_desc: "鼠标悬停在高亮词汇上时显示定义", 14 | enable_auto_highlight: "启用自动高亮", 15 | enable_auto_highlight_desc: "在阅读时自动高亮生词本中的词汇", 16 | highlight_style: "高亮样式", 17 | highlight_style_desc: "选择单词在文本中的高亮显示方式", 18 | style_underline: "下划线", 19 | style_background: "背景高亮", 20 | style_bold: "粗体", 21 | style_dotted: "点状下划线", 22 | style_wavy: "波浪线", 23 | save_settings: "保存设置", 24 | no_vocabulary_books: "暂无单词本,请添加 Canvas 文件作为单词本", 25 | path: "路径", 26 | reload_book: "重新解析该单词本", 27 | statistics: "统计信息", 28 | total_books: "总单词本数量: {0}", 29 | enabled_books: "已启用单词本: {0}", 30 | total_words: "总词汇数量: {0}", 31 | enable_mastered_feature: "启用已掌握功能", 32 | enable_mastered_feature_desc: "标记已掌握单词以停止高亮显示。侧边栏显示分组单词", 33 | // 已掌握判定模式 34 | mastered_detection: "已掌握判定模式", 35 | mastered_detection_desc: "选择用于判定“已掌握”的方式:分组或颜色(绿色=4)", 36 | mode_group: "分组模式", 37 | mode_color: "颜色模式(绿色=4)", 38 | blur_definitions: "模糊内容", 39 | blur_definitions_desc: "默认模糊显示内容,鼠标悬停时显示清晰内容。适合学习时先回忆再查看答案", 40 | // 发音地址模板 41 | tts_template: "发音地址模板", 42 | tts_template_desc: "使用 {{word}} 作为占位符,例如:https://dict.youdao.com/dictvoice?audio={{word}}&type=2", 43 | ai_dictionary: "AI 助手", 44 | ai_api_url: "API 地址", 45 | ai_api_url_desc: "完整的 API 端点地址(如:.../v1/chat/completions)", 46 | ai_api_key: "API Key", 47 | ai_api_key_desc: "你的 AI API 密钥", 48 | ai_model: "模型 ID", 49 | ai_model_desc: "AI 模型标识符 (例如: gpt-4o-mini, deepseek-chat)", 50 | ai_prompt: "自定义提示词", 51 | ai_prompt_desc: "使用 {{word}} 和 {{sentence}} 作为占位符。AI 将使用此提示词生成释义", 52 | // 自动布局 53 | auto_layout: "白板自动布局", 54 | enable_auto_layout: "启用自动布局", 55 | enable_auto_layout_desc: "在添加/更新/删除节点和分组变更后自动规范化布局", 56 | card_size: "卡片尺寸", 57 | card_size_desc: "默认卡片尺寸(宽 × 高,默认:260 × 120)", 58 | grid_gaps: "网格间距", 59 | grid_gaps_desc: "左侧区域网格的水平/垂直间距", 60 | left_padding: "左侧留白与最小X", 61 | left_padding_desc: "Mastered 分组左侧的留白与左侧区域的最小 X 位置", 62 | columns_auto: "自动列数", 63 | columns_auto_desc: "根据内容自适应列数(受最大列数限制)", 64 | columns: "固定列数/最大列数", 65 | columns_desc: "指定固定列数与最大列数(当关闭自动列数时生效)", 66 | group_inner_layout: "分组内布局", 67 | group_inner_layout_desc: "分组内部的内边距、间距与列数设置", 68 | // 高亮范围设置 69 | highlight_scope: "高亮范围", 70 | highlight_mode: "高亮模式", 71 | highlight_mode_desc: "选择高亮单词的范围模式", 72 | mode_all: "全部文件高亮", 73 | mode_exclude: "排除指定路径", 74 | mode_include: "仅指定路径高亮", 75 | highlight_paths: "文件路径", 76 | highlight_paths_desc: "多个路径用逗号分隔,支持文件夹路径(包括子文件夹)", 77 | highlight_paths_placeholder: "例如:Archive, Templates, Private/Diary", 78 | // 文件节点解析模式 79 | file_node_parse_mode: "文件节点解析模式", 80 | file_node_parse_mode_desc: "选择如何解析 Canvas 中的文件卡片", 81 | mode_filename: "仅文件名", 82 | mode_content: "仅文件内容", 83 | mode_filename_with_alias: "文件名作为主词,内容作为别名", 84 | }, 85 | 86 | // 侧边栏 87 | sidebar: { 88 | title: "HiWords", 89 | empty_state: "未找到单词。添加单词到您的生词本以在此处查看。", 90 | source_prefix: "来自: ", 91 | found: "发现", 92 | words: "个生词", 93 | no_definition: "暂无定义", 94 | vocabulary_book: "词汇", 95 | mastered: "已掌握", 96 | no_learning_words: "没有待学习的单词", 97 | no_mastered_words: "没有已掌握的单词", 98 | }, 99 | 100 | // 命令 101 | commands: { 102 | refresh_vocabulary: "刷新生词本", 103 | add_word: "HiWords: 添加单词", 104 | edit_word: "HiWords: 编辑单词", 105 | show_sidebar: "显示 HiWords 侧边栏", 106 | add_selected_word: "添加单词", 107 | }, 108 | 109 | // 通知消息 110 | notices: { 111 | enter_word_first: "请先输入单词", 112 | definition_fetched: "释义获取成功", 113 | definition_fetch_failed: "获取释义失败,请检查网络或尝试其他单词", 114 | vocabulary_refreshed: "生词本已刷新", 115 | word_added: "单词已添加到生词本", 116 | word_exists: "单词已存在于生词本中", 117 | error_adding_word: "添加单词到生词本时出错", 118 | select_book_required: "请选择生词本", 119 | adding_word: "正在添加词汇到生词本...", 120 | updating_word: "正在更新词汇...", 121 | word_added_success: "词汇 \"{0}\" 已成功添加到生词本", 122 | word_updated_success: "词汇 \"{0}\" 已成功更新", 123 | add_word_failed: "添加词汇失败,请检查生词本文件", 124 | update_word_failed: "更新词汇失败,请检查生词本文件", 125 | error_processing_word: "处理词汇时出错", 126 | no_canvas_files: "未找到 Canvas 文件", 127 | book_already_exists: "该生词本已存在", 128 | invalid_canvas_file: "无效的 Canvas 文件", 129 | book_added: "已添加生词本: {0}", 130 | book_reloaded: "已重新加载: {0}", 131 | book_removed: "已删除生词本: {0}", 132 | deleting_word: "正在删除词汇...", 133 | word_deleted: "词汇已删除", 134 | delete_word_failed: "删除词汇失败,请检查生词本文件", 135 | error_deleting_word: "删除词汇时发生错误", 136 | mastered_feature_disabled: "已掌握功能未启用", 137 | update_word_status_failed: "更新单词状态失败", 138 | move_to_mastered_group_failed: "移动到已掌握分组失败", 139 | word_marked_as_mastered: "\"{0}\"已标记为已掌握", 140 | mark_mastered_failed: "标记失败,请重试", 141 | remove_from_mastered_group_failed: "从已掌握分组移除失败", 142 | word_unmarked_as_mastered: "\"{0}\"已取消已掌握标记", 143 | no_text_selected: "请先选中要添加的单词", 144 | word_required: "请输入单词", 145 | unmark_mastered_failed: "取消标记失败,请重试", 146 | batch_marked_success: "成功标记 {0} 个单词为已掌握", 147 | book_path_updated: "单词本路径已更新: {0}", 148 | }, 149 | 150 | // 模态框 151 | modals: { 152 | auto_fill_definition: "AI 释义", 153 | word_label: "单词", 154 | word_placeholder: "输入要添加的单词...", 155 | definition_label: "释义", 156 | book_label: "单词本", 157 | select_book: "选择单词本", 158 | color_label: "卡片颜色", 159 | color_gray: "灰色", 160 | color_red: "红色", 161 | color_orange: "橙色", 162 | color_yellow: "黄色", 163 | color_green: "绿色", 164 | color_blue: "蓝色", 165 | color_purple: "紫色", 166 | aliases_label: "别名(可选,用逗号分隔)", 167 | aliases_placeholder: "例如:doing, done, did", 168 | definition_placeholder: "输入词汇释义...", 169 | add_button: "添加", 170 | save_button: "保存", 171 | cancel_button: "取消", 172 | select_canvas_file: "选择 Canvas 文件", 173 | delete_confirmation: "确定要删除词汇 \"{0}\" 吗?\n此操作不可撤销。", 174 | }, 175 | // 通用操作文案 176 | actions: { 177 | expand: "展开", 178 | collapse: "收起", 179 | mark_mastered: "已掌握", 180 | unmark_mastered: "忘记了", 181 | }, 182 | // AI 词典错误提示 183 | ai_errors: { 184 | word_empty: "单词不能为空", 185 | api_key_not_configured: "API Key 未配置,请在插件设置中填写", 186 | api_url_required: "API 地址不能为空,请在插件设置中填写", 187 | model_required: "模型 ID 不能为空,请在插件设置中填写", 188 | prompt_required: "自定义提示词不能为空,请在插件设置中填写", 189 | invalid_api_url: "API 地址格式无效,请检查插件设置中的 URL", 190 | prompt_missing_word_placeholder: "提示词必须包含 {{word}} 占位符", 191 | invalid_response: "API 返回了无效的响应格式", 192 | api_key_invalid: "❌ API Key 无效或已过期,请检查插件设置", 193 | rate_limit: "⏱️ API 请求频率超限,请稍后再试", 194 | server_error: "🔧 API 服务暂时不可用,请稍后再试", 195 | network_error: "🌐 网络连接失败,请检查网络设置", 196 | request_failed: "AI 词典请求失败", 197 | }, 198 | } 199 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { App, getLanguage } from 'obsidian'; 2 | import en from './en'; 3 | import zh from './zh'; 4 | import es from './es'; 5 | import fr from './fr'; 6 | import de from './de'; 7 | import ja from './ja'; 8 | 9 | // 支持的语言 10 | export type SupportedLocale = 'en' | 'zh' | 'es' | 'fr' | 'de' | 'ja'; 11 | 12 | // 语言包接口 13 | export interface LanguagePack { 14 | plugin_name: string; 15 | settings: { 16 | vocabulary_books: string; 17 | add_vocabulary_book: string; 18 | remove_vocabulary_book: string; 19 | show_definition_on_hover: string; 20 | show_definition_on_hover_desc: string; 21 | enable_auto_highlight: string; 22 | enable_auto_highlight_desc: string; 23 | highlight_style: string; 24 | highlight_style_desc: string; 25 | style_underline: string; 26 | style_background: string; 27 | style_bold: string; 28 | style_dotted: string; 29 | style_wavy: string; 30 | save_settings: string; 31 | no_vocabulary_books: string; 32 | path: string; 33 | reload_book: string; 34 | statistics: string; 35 | total_books: string; 36 | enabled_books: string; 37 | total_words: string; 38 | enable_mastered_feature: string; 39 | enable_mastered_feature_desc: string; 40 | // Mastered detection mode 41 | mastered_detection?: string; 42 | mastered_detection_desc?: string; 43 | mode_group?: string; 44 | mode_color?: string; 45 | blur_definitions: string; 46 | blur_definitions_desc: string; 47 | // TTS template (optional for backward compatibility) 48 | tts_template?: string; 49 | tts_template_desc?: string; 50 | // AI Dictionary 51 | ai_dictionary?: string; 52 | ai_api_url?: string; 53 | ai_api_url_desc?: string; 54 | ai_api_key?: string; 55 | ai_api_key_desc?: string; 56 | ai_model?: string; 57 | ai_model_desc?: string; 58 | ai_prompt?: string; 59 | ai_prompt_desc?: string; 60 | // Auto layout section 61 | auto_layout: string; 62 | enable_auto_layout: string; 63 | enable_auto_layout_desc: string; 64 | card_size: string; 65 | card_size_desc: string; 66 | grid_gaps: string; 67 | grid_gaps_desc: string; 68 | left_padding: string; 69 | left_padding_desc: string; 70 | columns_auto: string; 71 | columns_auto_desc: string; 72 | columns: string; 73 | columns_desc: string; 74 | group_inner_layout: string; 75 | group_inner_layout_desc: string; 76 | // Highlight scope settings 77 | highlight_scope?: string; 78 | highlight_mode?: string; 79 | highlight_mode_desc?: string; 80 | mode_all?: string; 81 | mode_exclude?: string; 82 | mode_include?: string; 83 | highlight_paths?: string; 84 | highlight_paths_desc?: string; 85 | highlight_paths_placeholder?: string; 86 | // File node parse mode 87 | file_node_parse_mode?: string; 88 | file_node_parse_mode_desc?: string; 89 | mode_filename?: string; 90 | mode_content?: string; 91 | mode_filename_with_alias?: string; 92 | }; 93 | sidebar: { 94 | title: string; 95 | empty_state: string; 96 | source_prefix: string; 97 | found: string; 98 | words: string; 99 | }; 100 | commands: { 101 | add_word: string; 102 | refresh_vocabulary: string; 103 | show_sidebar: string; 104 | }; 105 | notices: { 106 | vocabulary_refreshed: string; 107 | word_added: string; 108 | word_exists: string; 109 | error_adding_word: string; 110 | select_book_required: string; 111 | adding_word: string; 112 | word_added_success: string; 113 | add_word_failed: string; 114 | no_canvas_files: string; 115 | book_already_exists: string; 116 | invalid_canvas_file: string; 117 | book_added: string; 118 | book_reloaded: string; 119 | book_removed: string; 120 | }; 121 | modals: { 122 | auto_fill_definition?: string; 123 | word_label: string; 124 | word_placeholder: string; 125 | definition_label: string; 126 | book_label: string; 127 | select_book: string; 128 | color_label: string; 129 | color_gray: string; 130 | aliases_label: string; 131 | aliases_placeholder: string; 132 | definition_placeholder: string; 133 | add_button: string; 134 | cancel_button: string; 135 | select_canvas_file: string; 136 | delete_confirmation: string; 137 | save_button: string; 138 | }; 139 | // Common action labels used in UI 140 | actions?: { 141 | expand: string; // 展开 142 | collapse: string; // 收起 143 | mark_mastered: string; // 已掌握 144 | unmark_mastered: string; // 忘记了(取消已掌握) 145 | }; 146 | // AI dictionary error messages 147 | ai_errors?: { 148 | word_empty: string; 149 | api_key_not_configured: string; 150 | invalid_response: string; 151 | api_key_invalid: string; 152 | rate_limit: string; 153 | server_error: string; 154 | network_error: string; 155 | request_failed: string; 156 | }; 157 | } 158 | 159 | // 语言包集合 160 | const languagePacks: Record = { 161 | en, 162 | zh, 163 | es, 164 | fr, 165 | de, 166 | ja, 167 | }; 168 | 169 | /** 170 | * 国际化管理类 171 | */ 172 | export class I18n { 173 | private static instance: I18n; 174 | private app: App | null = null; 175 | 176 | /** 177 | * 获取单例实例 178 | */ 179 | public static getInstance(): I18n { 180 | if (!I18n.instance) { 181 | I18n.instance = new I18n(); 182 | } 183 | return I18n.instance; 184 | } 185 | 186 | /** 187 | * 设置 Obsidian App 实例 188 | */ 189 | public setApp(app: App): void { 190 | this.app = app; 191 | } 192 | 193 | /** 194 | * 获取当前语言 195 | */ 196 | private getCurrentLocale(): SupportedLocale { 197 | // 使用 Obsidian 官方 API 获取语言设置 (requires minAppVersion: "1.8.0") 198 | const obsidianLocale = getLanguage(); 199 | 200 | // 将 Obsidian 语言设置映射到我们支持的语言 201 | if (obsidianLocale.startsWith('zh')) { 202 | return 'zh'; 203 | } 204 | if (obsidianLocale.startsWith('es')) { 205 | return 'es'; 206 | } 207 | if (obsidianLocale.startsWith('fr')) { 208 | return 'fr'; 209 | } 210 | if (obsidianLocale.startsWith('de')) { 211 | return 'de'; 212 | } 213 | if (obsidianLocale.startsWith('ja') || obsidianLocale.startsWith('jp')) { 214 | return 'ja'; 215 | } 216 | 217 | // 默认返回英文 218 | return 'en'; 219 | } 220 | 221 | /** 222 | * 获取翻译文本 223 | * @param key 翻译键,支持点号分隔的路径,如 'sidebar.title' 224 | * @returns 翻译后的文本 225 | */ 226 | public t(key: string): string { 227 | const locale = this.getCurrentLocale(); 228 | const pack = languagePacks[locale]; 229 | const keys = key.split('.'); 230 | let result: any = pack; 231 | 232 | for (const k of keys) { 233 | if (result && result[k] !== undefined) { 234 | result = result[k]; 235 | } else { 236 | console.warn(`翻译键 ${key} 不存在于 ${locale} 语言包中`); 237 | return key; 238 | } 239 | } 240 | 241 | return result; 242 | } 243 | } 244 | 245 | // 导出单例实例 246 | export const i18n = I18n.getInstance(); 247 | 248 | // 导出翻译函数,方便使用 249 | export const t = (key: string): string => i18n.t(key); 250 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

HiWords - Smart Vocabulary Manager for Obsidian

3 | GitHub Downloads (all assets, all releases) 4 | GitHub release (latest by date) 5 | GitHub last commit 6 | GitHub issues 7 | GitHub stars 8 |
9 | 10 | --- 11 | 12 | [简体中文](./README-ZH.md) | English 13 | 14 | An intelligent Obsidian plugin that transforms your reading into an immersive vocabulary learning experience. HiWords automatically highlights unfamiliar words from your custom vocabulary books, provides instant definitions on hover, and helps you master new words effortlessly while reading. 15 | 16 | ![Screenshot](docs/screenshot.jpg) 17 | 18 | --- 19 | 20 | ## 📚 Canvas-Based Vocabulary Management 21 | 22 | Manage your vocabulary books using Obsidian's powerful Canvas feature. You can freely arrange vocabulary cards on Canvas with drag-and-drop, create multiple independent vocabulary books for different topics, languages, or learning goals, and use node colors to categorize words by difficulty, topic, or mastery level. All changes to your vocabulary books are automatically synced and reflected in your reading highlights. 23 | 24 | ![Vocabulary Management](docs/vocabulary_management.jpg) 25 | 26 | --- 27 | 28 | ## 🎯 Smart Highlighting System 29 | 30 | HiWords intelligently highlights vocabulary words in your notes, making it easy to spot and review words you're learning. It instantly recognizes and highlights words from your vocabulary books as you read, with highlight colors matching your Canvas node colors for visual consistency. You can flexibly choose to highlight in all files, specific folders, or exclude certain paths. Built on CodeMirror 6 for smooth performance even with large documents. 31 | 32 | Supports not only editing mode but also perfectly supports Markdown reading mode and PDF file highlighting, providing a consistent learning experience across all reading scenarios. 33 | 34 | ![PDF Support](docs/pdf_support.jpg) 35 | 36 | --- 37 | 38 | ## 💡 Instant Definitions on Hover 39 | 40 | Simply hover over any highlighted word to instantly view detailed definitions with Markdown formatting support, without leaving your current document. You can mark words as mastered directly in the popup, click the word to hear pronunciation (supports custom TTS services, defaults to English pronunciation), and the popup interface seamlessly adapts to your Obsidian theme for a consistent visual experience. 41 | 42 | --- 43 | 44 | ## 🤖 AI-Powered Definitions 45 | 46 | Configure your preferred AI service (supports OpenAI, Anthropic, and other compatible formats) to let AI automatically generate contextual definitions. You can customize prompt templates using `{{word}}` and `{{sentence}}` variables, quickly generate AI definitions when adding new words, helping you better understand words in specific contexts. 47 | 48 | ![AI Integration](docs/ai_integration.jpg) 49 | 50 | --- 51 | 52 | ## 📋 Sidebar Vocabulary View 53 | 54 | Open the sidebar with a quick command to track your vocabulary learning and see all words in the current document at a glance. Click any word to hear pronunciation, with colors matching Canvas node colors. You can toggle mastered words visibility to focus on active learning, and the list automatically updates in real-time as you edit or switch documents. 55 | 56 | --- 57 | 58 | ## ⚡ Quick Word Management 59 | 60 | Select any text and right-click to quickly add it to your vocabulary book, or use `Ctrl/Cmd+P` to add selected words via the command palette. The plugin intelligently detects if a word already exists and automatically switches to edit mode, capturing surrounding sentences for better context when adding. Supports efficient management of multiple words across different vocabulary books. 61 | 62 | ![Quick Add](docs/quick_add.jpg) 63 | 64 | --- 65 | 66 | ## 🚀 Getting Started 67 | 68 | ### Installation 69 | 70 | **From Obsidian Community Plugins (Recommended)** 71 | 72 | 1. Open Obsidian Settings → Community Plugins 73 | 2. Search for "HiWords" 74 | 3. Click Install, then Enable 75 | 76 | ### Creating Your First Vocabulary Book 77 | 78 | 1. **Create a Canvas file** 79 | 80 | - Right-click in file explorer → New Canvas 81 | - Name it (e.g., `English Vocabulary.canvas`) 82 | 83 | 2. **Add vocabulary cards** 84 | 85 | - Create text nodes with this format: 86 | 87 | ``` 88 | 89 | serendipity 90 | *serendipitous, serendipitously* 91 | 92 | **n.** The ability to make fortunate discoveries by accident 93 | 94 | **Example:** The discovery of penicillin was a fortunate serendipity. 95 | 96 | ``` 97 | 98 | 3. **Organize with colors** 99 | 100 | - Click nodes to set card colors 101 | - Use colors to categorize by difficulty, topic, or mastery 102 | 103 | 4. **Link to HiWords** 104 | 105 | - Open HiWords settings 106 | - Add your Canvas file as a vocabulary book 107 | - Start reading and watch words highlight automatically! 108 | 109 | > **Tips**: You can directly drag files into Canvas, and HiWords will automatically parse the file content and add it to your vocabulary book. Configure file node mode in HiWords settings to choose filename only or with aliases. 110 | 111 | --- 112 | 113 | ## ⚙️ Configuration 114 | 115 | ### Highlighting Settings 116 | 117 | - **Enable Auto Highlighting**: Toggle automatic word highlighting 118 | - **Highlight Style**: Choose highlight display style, supports background highlight, underline, bold, and more 119 | - **Highlight Scope**: All files (default), only specific folders, or exclude specific folders 120 | 121 | ### Hover Popup Settings 122 | 123 | - **Show on Hover**: Enable/disable definition popups 124 | - **Blur Definitions**: Blur definitions until you hover (for active recall practice) 125 | - **TTS Template**: Customize pronunciation service URL 126 | 127 | ### AI Assistant Settings 128 | 129 | - **API URL**: Your AI service endpoint 130 | - **API Key**: Authentication key for AI service 131 | - **Model**: AI model to use (e.g., gpt-4o-mini) 132 | - **Custom Prompt**: Design your prompt with `{{word}}` and `{{sentence}}` placeholders 133 | 134 | ### Canvas Settings 135 | 136 | - **Auto Layout**: Automatically arrange new vocabulary cards 137 | - **Card Size**: Set default width and height for vocabulary cards 138 | - **File Node Mode**: Choose how to parse file nodes (filename only or with aliases) 139 | 140 | ### Mastery Tracking 141 | 142 | - **Enable Mastery Feature**: Track which words you've mastered 143 | - **Show Mastered in Sidebar**: Display or hide mastered words in the sidebar view 144 | 145 | --- 146 | 147 | ## 🎯 Usage Tips 148 | 149 | ### Organizing Vocabulary Books 150 | 151 | - **By Language**: Create separate books for different languages 152 | - **By Topic**: Organize words by subject (business, academic, casual, etc.) 153 | - **By Source**: Keep words from different books or courses separate 154 | - **By Difficulty**: Use colors to mark beginner, intermediate, and advanced words 155 | 156 | ### Effective Learning Workflow 157 | 158 | 1. **Read naturally** - Let HiWords highlight words automatically 159 | 2. **Hover to review** - Check definitions without breaking flow 160 | 3. **Mark mastered** - Track your progress as you learn 161 | 4. **Add new words** - Right-click or use quick commands to add unfamiliar words 162 | 5. **Use AI assistance** - Generate contextual definitions for better understanding 163 | 164 | --- 165 | 166 | ## 📝 Commands 167 | 168 | Access these commands via `Ctrl/Cmd+P`: 169 | 170 | - **Refresh Vocabulary** - Reload all vocabulary books 171 | - **Show Vocabulary Sidebar** - Open the sidebar view 172 | - **Add Selected Word** - Add selected text to vocabulary 173 | 174 | --- 175 | 176 | ## 🔒 Privacy & Security 177 | 178 | HiWords is privacy-focused: all vocabulary data is stored locally in your vault, and the plugin works completely offline by default with no telemetry. The optional AI dictionary feature (disabled by default) sends words and sentences directly to your configured AI provider only when you manually use the auto-fill button. 179 | 180 | --- 181 | 182 | ## 🤝 Support 183 | 184 | If you find HiWords helpful, please consider supporting its development: 185 | 186 | - [☕ Buy me a coffee on Ko-fi](https://ko-fi.com/catmuse) 187 | - [⭐ Star the project on GitHub](https://github.com/CatMuse/HiWords) 188 | - [🐛 Report issues or suggest features](https://github.com/CatMuse/HiWords/issues) 189 | 190 | --- 191 | 192 | ## 📄 License 193 | 194 | MIT License - feel free to use and modify as needed. 195 | 196 | --- 197 | 198 | **Made with ❤️ by [CatMuse](https://github.com/CatMuse)** 199 | -------------------------------------------------------------------------------- /src/i18n/fr.ts: -------------------------------------------------------------------------------- 1 | // Français (French) language pack 2 | 3 | export default { 4 | // General 5 | plugin_name: "HiWords", 6 | 7 | // Settings 8 | settings: { 9 | vocabulary_books: "Livres de mots", 10 | add_vocabulary_book: "Ajouter un livre", 11 | remove_vocabulary_book: "Supprimer", 12 | show_definition_on_hover: "Afficher la définition au survol", 13 | show_definition_on_hover_desc: "Afficher la définition en survolant les mots surlignés", 14 | enable_auto_highlight: "Activer le surlignage auto", 15 | enable_auto_highlight_desc: "Surligner automatiquement les mots des livres pendant la lecture", 16 | highlight_style: "Style de surlignage", 17 | highlight_style_desc: "Choisissez la façon dont les mots sont surlignés", 18 | style_underline: "Souligné", 19 | style_background: "Fond", 20 | style_bold: "Gras", 21 | style_dotted: "Souligné en pointillés", 22 | style_wavy: "Souligné ondulé", 23 | save_settings: "Enregistrer les réglages", 24 | no_vocabulary_books: "Aucun livre pour l'instant. Ajoutez un fichier Canvas comme livre de mots.", 25 | path: "Chemin", 26 | reload_book: "Recharger ce livre", 27 | statistics: "Statistiques", 28 | total_books: "Livres au total : {0}", 29 | enabled_books: "Livres activés : {0}", 30 | total_words: "Mots au total : {0}", 31 | enable_mastered_feature: "Activer la fonction maîtrisé", 32 | enable_mastered_feature_desc: "Marquer les mots maîtrisés pour arrêter de les surligner. La barre latérale affiche les groupes", 33 | blur_definitions: "Flouter le contenu", 34 | blur_definitions_desc: "Flouter par défaut, révéler au survol. Idéal pour mémoriser avant de voir la réponse", 35 | // TTS template 36 | tts_template: "Modèle TTS", 37 | tts_template_desc: "Utiliser {{word}} comme espace réservé, ex. https://dict.youdao.com/dictvoice?audio={{word}}&type=2", 38 | // AI Dictionary 39 | ai_dictionary: "Assistant IA", 40 | ai_api_url: "URL de l'API", 41 | ai_api_url_desc: "Point de terminaison de l'API (détecte automatiquement : OpenAI, Claude, Gemini)", 42 | ai_api_key: "Clé API", 43 | ai_api_key_desc: "Votre clé API IA", 44 | ai_model: "Modèle", 45 | ai_model_desc: "Nom du modèle IA (ex., gpt-4o-mini, deepseek-chat)", 46 | ai_prompt: "Prompt personnalisé", 47 | ai_prompt_desc: "Utiliser {{word}} et {{sentence}} comme espaces réservés. L'IA utilisera ce prompt pour générer des définitions", 48 | // Auto layout 49 | auto_layout: "Disposition auto du canevas", 50 | enable_auto_layout: "Activer la disposition automatique", 51 | enable_auto_layout_desc: "Normaliser automatiquement la disposition après ajout/mise à jour/suppression et changements de groupe", 52 | card_size: "Taille de carte", 53 | card_size_desc: "Taille de carte par défaut (Largeur × Hauteur, par défaut : 260 × 120)", 54 | grid_gaps: "Espacement de la grille", 55 | grid_gaps_desc: "Espacement horizontal/vertical pour la grille de gauche", 56 | left_padding: "Marge gauche et X min", 57 | left_padding_desc: "Marge gauche depuis le groupe Mastered et X minimale pour la zone gauche", 58 | columns_auto: "Colonnes auto", 59 | columns_auto_desc: "Déterminer automatiquement les colonnes (limité par le max)", 60 | columns: "Colonnes fixes / Colonnes max", 61 | columns_desc: "Spécifier colonnes fixes et max (quand auto est désactivé)", 62 | group_inner_layout: "Disposition interne du groupe", 63 | group_inner_layout_desc: "Marge interne, espacements et colonnes pour les nœuds dans les groupes", 64 | highlight_scope: "Portée de surbrillance", 65 | highlight_mode: "Mode de surbrillance", 66 | highlight_mode_desc: "Choisir le mode de portée pour la surbrillance des mots", 67 | mode_all: "Tous les fichiers", 68 | mode_exclude: "Exclure les chemins spécifiés", 69 | mode_include: "Uniquement les chemins spécifiés", 70 | highlight_paths: "Chemins de fichiers", 71 | highlight_paths_desc: "Séparer plusieurs chemins par des virgules, prend en charge les chemins de dossiers (y compris les sous-dossiers)", 72 | highlight_paths_placeholder: "ex : Archive, Templates, Private/Diary", 73 | // File node parse mode 74 | file_node_parse_mode: "Mode d'analyse de noeud de fichier", 75 | file_node_parse_mode_desc: "Choisir comment analyser les cartes de fichiers dans Canvas", 76 | mode_filename: "Nom de fichier uniquement", 77 | mode_content: "Contenu de fichier uniquement", 78 | mode_filename_with_alias: "Nom de fichier comme mot, contenu comme alias", 79 | }, 80 | 81 | // Sidebar 82 | sidebar: { 83 | title: "HiWords", 84 | empty_state: "Aucun mot trouvé. Ajoutez des mots à vos livres pour les voir ici.", 85 | source_prefix: "De : ", 86 | found: "Trouvés", 87 | words: "mots", 88 | no_definition: "Aucune définition disponible.", 89 | vocabulary_book: "Mots", 90 | mastered: "Maîtrisés", 91 | no_learning_words: "Aucun mot à apprendre", 92 | no_mastered_words: "Aucun mot maîtrisé", 93 | }, 94 | 95 | // Commands 96 | commands: { 97 | refresh_vocabulary: "Actualiser le vocabulaire", 98 | add_word: "HiWords : Ajouter un mot", 99 | edit_word: "HiWords : Éditer un mot", 100 | show_sidebar: "Afficher la barre latérale HiWords", 101 | add_selected_word: "Ajouter un mot", 102 | }, 103 | 104 | // Notices 105 | notices: { 106 | vocabulary_refreshed: "Livres actualisés", 107 | word_added: "Mot ajouté au livre", 108 | word_exists: "Le mot existe déjà dans le livre", 109 | error_adding_word: "Erreur lors de l'ajout du mot au livre", 110 | select_book_required: "Veuillez sélectionner un livre de mots", 111 | adding_word: "Ajout du mot au livre...", 112 | updating_word: "Mise à jour du mot...", 113 | word_added_success: "Le mot \"{0}\" a été ajouté avec succès", 114 | word_updated_success: "Le mot \"{0}\" a été mis à jour avec succès", 115 | add_word_failed: "Échec de l'ajout du mot, vérifiez le fichier du livre", 116 | update_word_failed: "Échec de la mise à jour, vérifiez le fichier du livre", 117 | error_processing_word: "Erreur lors du traitement du mot", 118 | no_canvas_files: "Aucun fichier Canvas trouvé", 119 | book_already_exists: "Ce livre existe déjà", 120 | invalid_canvas_file: "Fichier Canvas invalide", 121 | book_added: "Livre ajouté : {0}", 122 | book_reloaded: "Livre rechargé : {0}", 123 | book_removed: "Livre supprimé : {0}", 124 | deleting_word: "Suppression du mot...", 125 | word_deleted: "Mot supprimé", 126 | delete_word_failed: "Échec de la suppression du mot, vérifiez le fichier du livre", 127 | error_deleting_word: "Une erreur est survenue lors de la suppression du mot", 128 | mastered_feature_disabled: "La fonction maîtrisé n'est pas activée", 129 | update_word_status_failed: "Échec de la mise à jour du statut du mot", 130 | move_to_mastered_group_failed: "Échec du déplacement vers le groupe maîtrisé", 131 | word_marked_as_mastered: "\"{0}\" marqué comme maîtrisé", 132 | mark_mastered_failed: "Échec du marquage, veuillez réessayer", 133 | remove_from_mastered_group_failed: "Échec du retrait du groupe maîtrisé", 134 | word_unmarked_as_mastered: "\"{0}\" retiré du statut maîtrisé", 135 | no_text_selected: "Veuillez d'abord sélectionner un mot à ajouter", 136 | word_required: "Veuillez entrer un mot", 137 | unmark_mastered_failed: "Échec de la suppression de la marque, veuillez réessayer", 138 | batch_marked_success: "{0} mots marqués comme maîtrisés avec succès", 139 | book_path_updated: "Chemin du livre mis à jour : {0}", 140 | new_notice: "Nouvelle notification", 141 | }, 142 | 143 | // Modals 144 | modals: { 145 | auto_fill_definition: "Définir avec IA", 146 | word_label: "Mot", 147 | word_placeholder: "Entrez le mot à ajouter...", 148 | definition_label: "Définition", 149 | book_label: "Livre de mots", 150 | select_book: "Sélectionnez un livre", 151 | color_label: "Couleur de la carte", 152 | color_gray: "Gris", 153 | color_red: "Rouge", 154 | color_orange: "Orange", 155 | color_yellow: "Jaune", 156 | color_green: "Vert", 157 | color_blue: "Bleu", 158 | color_purple: "Violet", 159 | aliases_label: "Alias (facultatif, séparés par des virgules)", 160 | aliases_placeholder: "ex. : doing, done, did", 161 | definition_placeholder: "Saisissez la définition...", 162 | add_button: "Ajouter", 163 | save_button: "Enregistrer", 164 | cancel_button: "Annuler", 165 | select_canvas_file: "Sélectionner le fichier du livre", 166 | delete_confirmation: "Êtes-vous sûr de vouloir supprimer le mot \"{0}\" ?\nCette action est irréversible.", 167 | }, 168 | // Libellés d'actions communs 169 | actions: { 170 | expand: "Déployer", 171 | collapse: "Replier", 172 | mark_mastered: "Marquer comme maîtrisé", 173 | unmark_mastered: "Retirer le statut maîtrisé", 174 | }, 175 | // Erreurs du dictionnaire IA 176 | ai_errors: { 177 | word_empty: "Le mot ne peut pas être vide", 178 | api_key_not_configured: "La clé API n'est pas configurée. Veuillez la définir dans les paramètres du plugin", 179 | invalid_response: "L'API a renvoyé un format de réponse invalide", 180 | api_key_invalid: "❌ La clé API est invalide ou expirée. Veuillez vérifier les paramètres du plugin", 181 | rate_limit: "⏱️ Limite de débit de l'API dépassée. Veuillez réessayer plus tard", 182 | server_error: "🔧 Le service API est temporairement indisponible. Veuillez réessayer plus tard", 183 | network_error: "🌐 Échec de la connexion réseau. Veuillez vérifier vos paramètres réseau", 184 | request_failed: "La requête au dictionnaire IA a échoué", 185 | }, 186 | } 187 | -------------------------------------------------------------------------------- /src/i18n/es.ts: -------------------------------------------------------------------------------- 1 | // Español (Spanish) language pack 2 | 3 | export default { 4 | // General 5 | plugin_name: "HiWords", 6 | 7 | // Settings 8 | settings: { 9 | vocabulary_books: "Libros de palabras", 10 | add_vocabulary_book: "Agregar libro de palabras", 11 | remove_vocabulary_book: "Eliminar", 12 | show_definition_on_hover: "Mostrar definición al pasar el cursor", 13 | show_definition_on_hover_desc: "Mostrar la definición al pasar el cursor sobre palabras resaltadas", 14 | enable_auto_highlight: "Habilitar resaltado automático", 15 | enable_auto_highlight_desc: "Resaltar automáticamente palabras de los libros mientras lees", 16 | highlight_style: "Estilo de resaltado", 17 | highlight_style_desc: "Elige cómo se resaltan las palabras en el texto", 18 | style_underline: "Subrayado", 19 | style_background: "Fondo", 20 | style_bold: "Negrita", 21 | style_dotted: "Subrayado punteado", 22 | style_wavy: "Subrayado ondulado", 23 | save_settings: "Guardar ajustes", 24 | no_vocabulary_books: "Aún no hay libros. Agrega un archivo Canvas como libro de palabras.", 25 | path: "Ruta", 26 | reload_book: "Recargar este libro", 27 | statistics: "Estadísticas", 28 | total_books: "Libros totales: {0}", 29 | enabled_books: "Libros habilitados: {0}", 30 | total_words: "Palabras totales: {0}", 31 | enable_mastered_feature: "Habilitar función de dominadas", 32 | enable_mastered_feature_desc: "Marca palabras dominadas para dejar de resaltarlas. La barra lateral muestra grupos", 33 | blur_definitions: "Difuminar contenido", 34 | blur_definitions_desc: "Difuminar por defecto, mostrar al pasar el cursor. Ideal para recordar antes de ver la respuesta", 35 | // TTS template 36 | tts_template: "Plantilla TTS", 37 | tts_template_desc: "Usar {{word}} como marcador de posición, ej. https://dict.youdao.com/dictvoice?audio={{word}}&type=2", 38 | // AI Dictionary 39 | ai_dictionary: "Asistente AI", 40 | ai_api_url: "URL de API", 41 | ai_api_url_desc: "Punto final de API (detecta automáticamente: OpenAI, Claude, Gemini)", 42 | ai_api_key: "Clave API", 43 | ai_api_key_desc: "Tu clave de API de AI", 44 | ai_model: "Modelo", 45 | ai_model_desc: "Nombre del modelo de AI (ej., gpt-4o-mini, deepseek-chat)", 46 | ai_prompt: "Prompt personalizado", 47 | ai_prompt_desc: "Usar {{word}} y {{sentence}} como marcadores de posición. La AI usará este prompt para generar definiciones", 48 | // Auto layout 49 | auto_layout: "Diseño automático del lienzo", 50 | enable_auto_layout: "Habilitar diseño automático", 51 | enable_auto_layout_desc: "Normalizar automáticamente el diseño después de agregar/actualizar/eliminar y cambios de grupo", 52 | card_size: "Tamaño de tarjeta", 53 | card_size_desc: "Tamaño de tarjeta predeterminado (Ancho × Alto, predeterminado: 260 × 120)", 54 | grid_gaps: "Espacios de la cuadrícula", 55 | grid_gaps_desc: "Espacios horizontal/vertical para la cuadrícula izquierda", 56 | left_padding: "Relleno izquierdo y X mínima", 57 | left_padding_desc: "Relleno izquierdo desde el grupo Mastered y X mínima para el área izquierda", 58 | columns_auto: "Columnas automáticas", 59 | columns_auto_desc: "Determinar columnas automáticamente (limitado por columnas máximas)", 60 | columns: "Columnas fijas / Máximo de columnas", 61 | columns_desc: "Especificar columnas fijas y máximo de columnas (efectivo cuando auto está desactivado)", 62 | group_inner_layout: "Diseño interno del grupo", 63 | group_inner_layout_desc: "Relleno interno, espacios y columnas para nodos dentro de grupos", 64 | highlight_scope: "Ámbito de resaltado", 65 | highlight_mode: "Modo de resaltado", 66 | highlight_mode_desc: "Elegir el modo de ámbito para el resaltado de palabras", 67 | mode_all: "Todos los archivos", 68 | mode_exclude: "Excluir rutas especificadas", 69 | mode_include: "Solo rutas especificadas", 70 | highlight_paths: "Rutas de archivo", 71 | highlight_paths_desc: "Separar múltiples rutas con comas, admite rutas de carpetas (incluidas subcarpetas)", 72 | highlight_paths_placeholder: "ej.: Archive, Templates, Private/Diary", 73 | // File node parse mode 74 | file_node_parse_mode: "Modo de análisis de nodo de archivo", 75 | file_node_parse_mode_desc: "Elegir cómo analizar tarjetas de archivo en Canvas", 76 | mode_filename: "Solo nombre de archivo", 77 | mode_content: "Solo contenido de archivo", 78 | mode_filename_with_alias: "Nombre de archivo como palabra, contenido como alias", 79 | }, 80 | 81 | // Sidebar 82 | sidebar: { 83 | title: "HiWords", 84 | empty_state: "No se encontraron palabras. Agrega palabras a tus libros para verlas aquí.", 85 | source_prefix: "De: ", 86 | found: "Encontradas", 87 | words: "palabras", 88 | no_definition: "Sin definición disponible.", 89 | vocabulary_book: "Palabras", 90 | mastered: "Dominadas", 91 | no_learning_words: "No hay palabras para aprender", 92 | no_mastered_words: "No hay palabras dominadas", 93 | }, 94 | 95 | // Commands 96 | commands: { 97 | refresh_vocabulary: "Actualizar vocabulario", 98 | add_word: "HiWords: Agregar palabra", 99 | edit_word: "HiWords: Editar palabra", 100 | show_sidebar: "Mostrar barra lateral de HiWords", 101 | add_selected_word: "Agregar palabra", 102 | }, 103 | 104 | // Notices 105 | notices: { 106 | vocabulary_refreshed: "Libros actualizados", 107 | word_added: "Palabra agregada al libro", 108 | word_exists: "La palabra ya existe en el libro", 109 | error_adding_word: "Error al agregar palabra al libro", 110 | select_book_required: "Selecciona un libro de palabras", 111 | adding_word: "Agregando palabra al libro...", 112 | updating_word: "Actualizando palabra...", 113 | word_added_success: "La palabra \"{0}\" se agregó correctamente", 114 | word_updated_success: "La palabra \"{0}\" se actualizó correctamente", 115 | add_word_failed: "No se pudo agregar la palabra, revisa el archivo del libro", 116 | update_word_failed: "No se pudo actualizar la palabra, revisa el archivo del libro", 117 | error_processing_word: "Error al procesar la palabra", 118 | no_canvas_files: "No se encontraron archivos Canvas", 119 | book_already_exists: "Este libro ya existe", 120 | invalid_canvas_file: "Archivo Canvas no válido", 121 | book_added: "Libro agregado: {0}", 122 | book_reloaded: "Libro recargado: {0}", 123 | book_removed: "Libro eliminado: {0}", 124 | deleting_word: "Eliminando palabra...", 125 | word_deleted: "Palabra eliminada", 126 | delete_word_failed: "No se pudo eliminar la palabra, revisa el archivo del libro", 127 | error_deleting_word: "Ocurrió un error al eliminar la palabra", 128 | mastered_feature_disabled: "La función de dominadas no está habilitada", 129 | update_word_status_failed: "No se pudo actualizar el estado de la palabra", 130 | move_to_mastered_group_failed: "No se pudo mover al grupo de dominadas", 131 | word_marked_as_mastered: "\"{0}\" marcada como dominada", 132 | mark_mastered_failed: "No se pudo marcar como dominada, inténtalo de nuevo", 133 | remove_from_mastered_group_failed: "No se pudo quitar del grupo de dominadas", 134 | word_unmarked_as_mastered: "\"{0}\" ya no está marcada como dominada", 135 | no_text_selected: "Por favor, selecciona primero una palabra para agregar", 136 | word_required: "Por favor, ingresa una palabra", 137 | unmark_mastered_failed: "No se pudo desmarcar, inténtalo de nuevo", 138 | batch_marked_success: "Se marcaron correctamente {0} palabras como dominadas", 139 | book_path_updated: "Ruta del libro actualizada: {0}", 140 | new_notice: "Nuevo aviso", 141 | }, 142 | 143 | // Modals 144 | modals: { 145 | auto_fill_definition: "Definir con AI", 146 | word_label: "Palabra", 147 | word_placeholder: "Ingresa la palabra a agregar...", 148 | definition_label: "Definición", 149 | book_label: "Libro de palabras", 150 | select_book: "Selecciona un libro", 151 | color_label: "Color de tarjeta", 152 | color_gray: "Gris", 153 | color_red: "Rojo", 154 | color_orange: "Naranja", 155 | color_yellow: "Amarillo", 156 | color_green: "Verde", 157 | color_blue: "Azul", 158 | color_purple: "Púrpura", 159 | aliases_label: "Alias (opcional, separados por comas)", 160 | aliases_placeholder: "p. ej.: doing, done, did", 161 | definition_placeholder: "Introduce la definición...", 162 | add_button: "Agregar", 163 | save_button: "Guardar", 164 | cancel_button: "Cancelar", 165 | select_canvas_file: "Seleccionar archivo del libro", 166 | delete_confirmation: "¿Seguro que deseas eliminar la palabra \"{0}\"?\nEsta acción no se puede deshacer.", 167 | }, 168 | // Etiquetas comunes de acciones 169 | actions: { 170 | expand: "Expandir", 171 | collapse: "Contraer", 172 | mark_mastered: "Marcar como dominado", 173 | unmark_mastered: "Desmarcar dominado", 174 | }, 175 | // Errores del diccionario AI 176 | ai_errors: { 177 | word_empty: "La palabra no puede estar vacía", 178 | api_key_not_configured: "La clave API no está configurada. Por favor, configúrela en los ajustes del plugin", 179 | invalid_response: "La API devolvió un formato de respuesta no válido", 180 | api_key_invalid: "❌ La clave API no es válida o ha caducado. Por favor, verifique la configuración del plugin", 181 | rate_limit: "⏱️ Límite de tasa de API excedido. Por favor, inténtelo de nuevo más tarde", 182 | server_error: "🔧 El servicio API no está disponible temporalmente. Por favor, inténtelo de nuevo más tarde", 183 | network_error: "🌐 Fallo en la conexión de red. Por favor, verifique su configuración de red", 184 | request_failed: "Solicitud al diccionario AI falló", 185 | }, 186 | } 187 | -------------------------------------------------------------------------------- /src/ui/pdf-highlighter.ts: -------------------------------------------------------------------------------- 1 | import type { HiWordsSettings } from '../utils'; 2 | import { Trie, mapCanvasColorToCSSVar } from '../utils'; 3 | import type { VocabularyManager } from '../core'; 4 | import { isElementVisible, buildTrieFromVocabulary } from '../utils/highlight-utils'; 5 | 6 | /** 7 | * 在 PDF 视图中注册单词高亮功能 8 | * 通过监听 PDF 文本层的渲染,实现对 PDF 内容的单词高亮 9 | */ 10 | export function registerPDFHighlighter(plugin: { 11 | settings: HiWordsSettings; 12 | vocabularyManager: VocabularyManager; 13 | shouldHighlightFile: (filePath: string) => boolean; 14 | app: any; 15 | registerEvent: (eventRef: any) => void; 16 | }): void { 17 | 18 | // 使用公共的 Trie 构建函数 19 | 20 | // 已处理的文本层集合,避免重复处理 21 | const processedTextLayers = new WeakSet(); 22 | 23 | // 防抖定时器 24 | let debounceTimer: number | null = null; 25 | 26 | /** 27 | * 处理 PDF 文本层高亮 28 | */ 29 | const processPDFTextLayer = (textLayer: HTMLElement, trie: Trie) => { 30 | // 避免重复处理同一个文本层 31 | if (processedTextLayers.has(textLayer)) { 32 | return; 33 | } 34 | processedTextLayers.add(textLayer); 35 | 36 | try { 37 | highlightPDFTextSpans(textLayer, trie, plugin.settings.highlightStyle || 'underline'); 38 | } catch (error) { 39 | console.error('PDF 文本层高亮处理失败:', error); 40 | } 41 | }; 42 | 43 | /** 44 | * 防抖处理 PDF 高亮更新 45 | */ 46 | const debouncedProcessPDF = () => { 47 | if (debounceTimer) { 48 | window.clearTimeout(debounceTimer); 49 | } 50 | 51 | debounceTimer = window.setTimeout(() => { 52 | if (!plugin.settings.enableAutoHighlight) return; 53 | 54 | // 获取当前活动文件 55 | const activeFile = plugin.app.workspace.getActiveFile(); 56 | if (activeFile && !plugin.shouldHighlightFile(activeFile.path)) { 57 | return; 58 | } 59 | 60 | const trie = buildTrieFromVocabulary(plugin.vocabularyManager); 61 | 62 | // 查找所有 PDF 文本层 63 | const textLayers = document.querySelectorAll('.textLayer'); 64 | textLayers.forEach((textLayer) => { 65 | // 检查是否在 PDF 视图中 66 | const pdfContainer = textLayer.closest('.pdf-container, .mod-pdf'); 67 | if (pdfContainer) { 68 | processPDFTextLayer(textLayer as HTMLElement, trie); 69 | } 70 | }); 71 | 72 | debounceTimer = null; 73 | }, 300); 74 | }; 75 | 76 | /** 77 | * 监听 PDF 视图变化 78 | */ 79 | const setupPDFObserver = () => { 80 | const observer = new MutationObserver((mutations) => { 81 | let shouldProcess = false; 82 | 83 | mutations.forEach((mutation) => { 84 | // 检查新增的节点 85 | mutation.addedNodes.forEach((node) => { 86 | if (node.nodeType === Node.ELEMENT_NODE) { 87 | const element = node as HTMLElement; 88 | 89 | // 检测 PDF 文本层 90 | if (element.classList.contains('textLayer') || 91 | element.querySelector('.textLayer')) { 92 | shouldProcess = true; 93 | } 94 | 95 | // 检测 PDF 页面容器 96 | if (element.classList.contains('page') && 97 | element.closest('.pdf-container, .mod-pdf')) { 98 | shouldProcess = true; 99 | } 100 | } 101 | }); 102 | }); 103 | 104 | if (shouldProcess) { 105 | debouncedProcessPDF(); 106 | } 107 | }); 108 | 109 | observer.observe(document.body, { 110 | childList: true, 111 | subtree: true, 112 | attributes: false, 113 | characterData: false 114 | }); 115 | 116 | return observer; 117 | }; 118 | 119 | /** 120 | * 监听工作区布局变化 121 | */ 122 | plugin.registerEvent( 123 | plugin.app.workspace.on('layout-change', () => { 124 | // 延迟处理,确保 PDF 视图完全加载 125 | setTimeout(() => { 126 | debouncedProcessPDF(); 127 | }, 500); 128 | }) 129 | ); 130 | 131 | /** 132 | * 监听活动叶子变化 133 | */ 134 | plugin.registerEvent( 135 | plugin.app.workspace.on('active-leaf-change', (leaf: any) => { 136 | if (leaf?.view?.getViewType() === 'pdf') { 137 | // 当切换到 PDF 视图时,延迟处理高亮 138 | setTimeout(() => { 139 | debouncedProcessPDF(); 140 | }, 1000); 141 | } 142 | }) 143 | ); 144 | 145 | /** 146 | * 监听文件打开事件 147 | */ 148 | plugin.registerEvent( 149 | plugin.app.workspace.on('file-open', (file: any) => { 150 | if (file?.extension === 'pdf') { 151 | setTimeout(() => { 152 | debouncedProcessPDF(); 153 | }, 1500); 154 | } 155 | }) 156 | ); 157 | 158 | // 设置 DOM 观察者 159 | const observer = setupPDFObserver(); 160 | 161 | // 初始处理已存在的 PDF 视图 162 | setTimeout(() => { 163 | debouncedProcessPDF(); 164 | }, 1000); 165 | 166 | // 清理函数(如果需要的话) 167 | const cleanup = () => { 168 | if (debounceTimer) { 169 | window.clearTimeout(debounceTimer); 170 | } 171 | observer.disconnect(); 172 | // WeakSet 没有 clear 方法,重新创建一个新的 WeakSet 173 | // processedTextLayers 会在函数作用域结束时自动清理 174 | }; 175 | 176 | // 将清理函数存储到插件实例上(可选) 177 | (plugin as any)._pdfHighlighterCleanup = cleanup; 178 | 179 | // 导出刷新函数到插件实例 180 | (plugin as any)._refreshPDFHighlighter = () => { 181 | refreshVisiblePDFPages(plugin, processedTextLayers); 182 | }; 183 | } 184 | 185 | /** 186 | * 清除 PDF 文本层中的所有高亮标记 187 | */ 188 | function clearPDFHighlights(textLayer: HTMLElement): void { 189 | const textSpans = textLayer.querySelectorAll('span[role="presentation"]'); 190 | 191 | textSpans.forEach(span => { 192 | // 查找高亮元素 193 | const highlights = span.querySelectorAll('.hi-words-highlight'); 194 | if (highlights.length === 0) return; 195 | 196 | // 提取纯文本内容 197 | let textContent = ''; 198 | span.childNodes.forEach(node => { 199 | if (node.nodeType === Node.TEXT_NODE) { 200 | textContent += node.textContent; 201 | } else if (node.nodeType === Node.ELEMENT_NODE) { 202 | textContent += (node as HTMLElement).textContent; 203 | } 204 | }); 205 | 206 | // 清空并重置为纯文本 207 | span.innerHTML = ''; 208 | span.textContent = textContent; 209 | }); 210 | } 211 | 212 | /** 213 | * 高亮 PDF 文本层中的所有文本 span 214 | */ 215 | function highlightPDFTextSpans(textLayer: HTMLElement, trie: Trie, highlightStyle: string): void { 216 | const textSpans = textLayer.querySelectorAll('span[role="presentation"]'); 217 | 218 | textSpans.forEach(span => { 219 | // 跳过已经高亮的元素和 tooltip 内容 220 | if (span.closest('.hi-words-highlight') || span.closest('.hi-words-tooltip')) { 221 | return; 222 | } 223 | 224 | const text = span.textContent || ''; 225 | if (!text.trim()) return; 226 | 227 | const matches = trie.findAllMatches(text) as Array<{ 228 | from: number; 229 | to: number; 230 | word: string; 231 | payload: any; 232 | }>; 233 | 234 | if (!matches || matches.length === 0) return; 235 | 236 | // 处理匹配结果,避免重叠 237 | matches.sort((a, b) => a.from - b.from || (b.to - b.from) - (a.to - a.from)); 238 | const filtered: typeof matches = []; 239 | let end = 0; 240 | for (const m of matches) { 241 | if (m.from >= end) { 242 | filtered.push(m); 243 | end = m.to; 244 | } 245 | } 246 | 247 | if (filtered.length === 0) return; 248 | 249 | // 创建高亮元素 250 | const frag = document.createDocumentFragment(); 251 | let last = 0; 252 | 253 | for (const match of filtered) { 254 | // 添加匹配前的文本 255 | if (match.from > last) { 256 | frag.appendChild(document.createTextNode(text.slice(last, match.from))); 257 | } 258 | 259 | // 创建高亮元素 260 | const def = match.payload; 261 | const color = mapCanvasColorToCSSVar(def?.color, 'var(--color-base-60)'); 262 | const highlightSpan = document.createElement('span'); 263 | 264 | highlightSpan.className = 'hi-words-highlight hi-words-pdf-highlight'; 265 | highlightSpan.setAttribute('data-word', match.word); 266 | if (def?.definition) highlightSpan.setAttribute('data-definition', def.definition); 267 | if (color) highlightSpan.setAttribute('data-color', color); 268 | highlightSpan.setAttribute('data-style', highlightStyle); 269 | if (color) highlightSpan.setAttribute('style', `--word-highlight-color: ${color}`); 270 | highlightSpan.textContent = text.slice(match.from, match.to); 271 | 272 | frag.appendChild(highlightSpan); 273 | last = match.to; 274 | } 275 | 276 | // 添加剩余文本 277 | if (last < text.length) { 278 | frag.appendChild(document.createTextNode(text.slice(last))); 279 | } 280 | 281 | // 替换原始内容 282 | span.innerHTML = ''; 283 | span.appendChild(frag); 284 | }); 285 | } 286 | 287 | /** 288 | * 刷新可见区域的 PDF 高亮 289 | */ 290 | function refreshVisiblePDFPages( 291 | plugin: { 292 | settings: HiWordsSettings; 293 | vocabularyManager: VocabularyManager; 294 | shouldHighlightFile: (filePath: string) => boolean; 295 | app: any; 296 | }, 297 | processedTextLayers: WeakSet 298 | ): void { 299 | if (!plugin.settings.enableAutoHighlight) return; 300 | 301 | try { 302 | // 重新构建 Trie 303 | const trie = buildTrieFromVocabulary(plugin.vocabularyManager); 304 | 305 | // 查找所有 PDF 文本层 306 | const textLayers = document.querySelectorAll('.textLayer'); 307 | 308 | textLayers.forEach(textLayer => { 309 | const htmlTextLayer = textLayer as HTMLElement; 310 | 311 | // 检查是否在 PDF 视图中 312 | const pdfContainer = htmlTextLayer.closest('.pdf-container, .mod-pdf'); 313 | if (!pdfContainer) return; 314 | 315 | // 只处理可见的文本层 316 | if (!isElementVisible(htmlTextLayer)) return; 317 | 318 | // 清除该文本层的已处理标记 319 | processedTextLayers.delete(htmlTextLayer); 320 | 321 | // 清除现有高亮 322 | clearPDFHighlights(htmlTextLayer); 323 | 324 | // 重新高亮 325 | highlightPDFTextSpans(htmlTextLayer, trie, plugin.settings.highlightStyle || 'underline'); 326 | 327 | // 标记为已处理 328 | processedTextLayers.add(htmlTextLayer); 329 | }); 330 | } catch (error) { 331 | console.error('刷新 PDF 高亮失败:', error); 332 | } 333 | } 334 | 335 | /** 336 | * 清理 PDF 高亮器资源 337 | */ 338 | export function cleanupPDFHighlighter(plugin: any): void { 339 | if (plugin._pdfHighlighterCleanup) { 340 | plugin._pdfHighlighterCleanup(); 341 | delete plugin._pdfHighlighterCleanup; 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/i18n/de.ts: -------------------------------------------------------------------------------- 1 | // Deutsch (German) language pack 2 | 3 | export default { 4 | // General 5 | plugin_name: "HiWords", 6 | 7 | // Settings 8 | settings: { 9 | vocabulary_books: "Wörterbücher", 10 | add_vocabulary_book: "Wörterbuch hinzufügen", 11 | remove_vocabulary_book: "Entfernen", 12 | show_definition_on_hover: "Definition beim Hover anzeigen", 13 | show_definition_on_hover_desc: "Definition anzeigen, wenn der Mauszeiger über hervorgehobene Wörter fährt", 14 | enable_auto_highlight: "Automatisches Hervorheben aktivieren", 15 | enable_auto_highlight_desc: "Wörter aus den Wörterbüchern beim Lesen automatisch hervorheben", 16 | highlight_style: "Hervorhebungsstil", 17 | highlight_style_desc: "Wählen Sie, wie Wörter im Text hervorgehoben werden", 18 | style_underline: "Unterstreichen", 19 | style_background: "Hintergrund", 20 | style_bold: "Fett", 21 | style_dotted: "Gepunktete Unterstreichung", 22 | style_wavy: "Wellenförmige Unterstreichung", 23 | save_settings: "Einstellungen speichern", 24 | no_vocabulary_books: "Noch keine Wörterbücher. Fügen Sie eine Canvas-Datei als Wörterbuch hinzu.", 25 | path: "Pfad", 26 | reload_book: "Dieses Wörterbuch neu laden", 27 | statistics: "Statistiken", 28 | total_books: "Gesamtzahl der Wörterbücher: {0}", 29 | enabled_books: "Aktivierte Wörterbücher: {0}", 30 | total_words: "Gesamtzahl der Wörter: {0}", 31 | enable_mastered_feature: "Funktion 'Gemeistert' aktivieren", 32 | enable_mastered_feature_desc: "Gemeisterte Wörter markieren, um sie nicht mehr hervorzuheben. Seitenleiste zeigt Gruppen", 33 | blur_definitions: "Inhalt verwischen", 34 | blur_definitions_desc: "Standardmäßig verwischen, beim Hover anzeigen. Ideal zum Erinnern vor dem Anzeigen der Antwort", 35 | tts_template: "TTS-Vorlage", 36 | tts_template_desc: "{{word}} als Platzhalter verwenden, z.B. https://dict.youdao.com/dictvoice?audio={{word}}&type=2", 37 | ai_dictionary: "KI-Assistent", 38 | ai_api_url: "API-URL", 39 | ai_api_url_desc: "API-Endpunkt (erkennt automatisch: OpenAI, Claude, Gemini)", 40 | ai_api_key: "API-Schlüssel", 41 | ai_api_key_desc: "Ihr KI-API-Schlüssel", 42 | ai_model: "Modell", 43 | ai_model_desc: "KI-Modellname (z.B. gpt-4o-mini, deepseek-chat)", 44 | ai_prompt: "Benutzerdefinierter Prompt", 45 | ai_prompt_desc: "{{word}} und {{sentence}} als Platzhalter verwenden. Die KI wird diesen Prompt verwenden, um Definitionen zu generieren", 46 | // Auto layout 47 | auto_layout: "Automatisches Canvas-Layout", 48 | enable_auto_layout: "Automatisches Layout aktivieren", 49 | enable_auto_layout_desc: "Layout nach Hinzufügen/Aktualisieren/Löschen und Gruppenänderungen automatisch normalisieren", 50 | card_size: "Kartengröße", 51 | card_size_desc: "Standard-Kartengröße (Breite × Höhe, Standard: 260 × 120)", 52 | grid_gaps: "Gitterabstände", 53 | grid_gaps_desc: "Horizontale/vertikale Abstände für das linke Gitter", 54 | left_padding: "Linker Innenabstand & min. X", 55 | left_padding_desc: "Linker Abstand zur Gruppe 'Mastered' und minimale X-Position für den linken Bereich", 56 | columns_auto: "Automatische Spalten", 57 | columns_auto_desc: "Spalten automatisch bestimmen (begrenzt durch maximale Spalten)", 58 | columns: "Feste Spalten / Maximale Spalten", 59 | columns_desc: "Feste Spalten und Maximalspalten angeben (wirksam, wenn auto aus)", 60 | group_inner_layout: "Inneres Gruppenlayout", 61 | group_inner_layout_desc: "Innenabstand, Abstände und Spalten für Knoten in Gruppen", 62 | highlight_scope: "Hervorhebungsbereich", 63 | highlight_mode: "Hervorhebungsmodus", 64 | highlight_mode_desc: "Wählen Sie den Bereichsmodus für die Wort-Hervorhebung", 65 | mode_all: "Alle Dateien", 66 | mode_exclude: "Angegebene Pfade ausschließen", 67 | mode_include: "Nur angegebene Pfade", 68 | highlight_paths: "Dateipfade", 69 | highlight_paths_desc: "Mehrere Pfade durch Kommas trennen, unterstützt Ordnerpfade (einschließlich Unterordner)", 70 | highlight_paths_placeholder: "z.B.: Archive, Templates, Private/Diary", 71 | // File node parse mode 72 | file_node_parse_mode: "Dateiknoten-Analysemodus", 73 | file_node_parse_mode_desc: "Wählen Sie, wie Dateikarten in Canvas analysiert werden", 74 | mode_filename: "Nur Dateiname", 75 | mode_content: "Nur Dateiinhalt", 76 | mode_filename_with_alias: "Dateiname als Wort, Inhalt als Alias", 77 | }, 78 | 79 | // Sidebar 80 | sidebar: { 81 | title: "HiWords", 82 | empty_state: "Keine Wörter gefunden. Fügen Sie Wörter zu Ihren Wörterbüchern hinzu, um sie hier zu sehen.", 83 | source_prefix: "Von: ", 84 | found: "Gefunden", 85 | words: "Wörter", 86 | no_definition: "Keine Definition verfügbar.", 87 | vocabulary_book: "Wörter", 88 | mastered: "Gemeistert", 89 | no_learning_words: "Keine zu lernenden Wörter", 90 | no_mastered_words: "Keine gemeisterten Wörter", 91 | }, 92 | 93 | // Commands 94 | commands: { 95 | refresh_vocabulary: "Vokabular aktualisieren", 96 | add_word: "HiWords: Wort hinzufügen", 97 | edit_word: "HiWords: Wort bearbeiten", 98 | show_sidebar: "HiWords-Seitenleiste anzeigen", 99 | add_selected_word: "Wort hinzufügen", 100 | }, 101 | 102 | // Notices 103 | notices: { 104 | vocabulary_refreshed: "Wörterbücher aktualisiert", 105 | word_added: "Wort zum Wörterbuch hinzugefügt", 106 | word_exists: "Wort existiert bereits im Wörterbuch", 107 | error_adding_word: "Fehler beim Hinzufügen des Wortes zum Wörterbuch", 108 | select_book_required: "Bitte wählen Sie ein Wörterbuch aus", 109 | adding_word: "Wort wird zum Wörterbuch hinzugefügt...", 110 | updating_word: "Wort wird aktualisiert...", 111 | word_added_success: "Wort \"{0}\" erfolgreich hinzugefügt", 112 | word_updated_success: "Wort \"{0}\" erfolgreich aktualisiert", 113 | add_word_failed: "Hinzufügen des Wortes fehlgeschlagen, prüfen Sie die Datei", 114 | update_word_failed: "Aktualisieren des Wortes fehlgeschlagen, prüfen Sie die Datei", 115 | error_processing_word: "Fehler bei der Verarbeitung des Wortes", 116 | no_canvas_files: "Keine Canvas-Dateien gefunden", 117 | book_already_exists: "Dieses Wörterbuch existiert bereits", 118 | invalid_canvas_file: "Ungültige Canvas-Datei", 119 | book_added: "Wörterbuch hinzugefügt: {0}", 120 | book_reloaded: "Wörterbuch neu geladen: {0}", 121 | book_removed: "Wörterbuch entfernt: {0}", 122 | deleting_word: "Wort wird gelöscht...", 123 | word_deleted: "Wort gelöscht", 124 | delete_word_failed: "Löschen des Wortes fehlgeschlagen, prüfen Sie die Datei", 125 | error_deleting_word: "Beim Löschen des Wortes ist ein Fehler aufgetreten", 126 | mastered_feature_disabled: "Funktion 'Gemeistert' ist nicht aktiviert", 127 | update_word_status_failed: "Aktualisieren des Wortstatus fehlgeschlagen", 128 | move_to_mastered_group_failed: "Verschieben in die Gruppe 'Gemeistert' fehlgeschlagen", 129 | word_marked_as_mastered: "\"{0}\" als gemeistert markiert", 130 | mark_mastered_failed: "Als gemeistert markieren fehlgeschlagen, bitte erneut versuchen", 131 | remove_from_mastered_group_failed: "Entfernen aus der Gruppe 'Gemeistert' fehlgeschlagen", 132 | word_unmarked_as_mastered: "\"{0}\" nicht mehr als gemeistert markiert", 133 | no_text_selected: "Bitte wählen Sie zuerst ein Wort aus", 134 | word_required: "Bitte geben Sie ein Wort ein", 135 | unmark_mastered_failed: "Markierung konnte nicht entfernt werden, bitte erneut versuchen", 136 | batch_marked_success: "{0} Wörter erfolgreich als gemeistert markiert", 137 | book_path_updated: "Wörterbuchpfad aktualisiert: {0}", 138 | new_notice: "Neue Benachrichtigung", 139 | }, 140 | 141 | // Modals 142 | modals: { 143 | auto_fill_definition: "KI-Definition", 144 | word_label: "Wort", 145 | word_placeholder: "Wort eingeben...", 146 | definition_label: "Definition", 147 | book_label: "Wörterbuch", 148 | select_book: "Wörterbuch auswählen", 149 | color_label: "Kartenfarbe", 150 | color_gray: "Grau", 151 | color_red: "Rot", 152 | color_orange: "Orange", 153 | color_yellow: "Gelb", 154 | color_green: "Grün", 155 | color_blue: "Blau", 156 | color_purple: "Lila", 157 | aliases_label: "Aliasse (optional, durch Kommas getrennt)", 158 | aliases_placeholder: "z. B.: doing, done, did", 159 | definition_placeholder: "Definition eingeben...", 160 | add_button: "Hinzufügen", 161 | save_button: "Speichern", 162 | cancel_button: "Abbrechen", 163 | select_canvas_file: "Datei des Wörterbuchs auswählen", 164 | delete_confirmation: "Möchten Sie das Wort \"{0}\" wirklich löschen?\nDiese Aktion kann nicht rückgängig gemacht werden.", 165 | }, 166 | // Allgemeine Aktionsbeschriftungen 167 | actions: { 168 | expand: "Erweitern", 169 | collapse: "Einklappen", 170 | mark_mastered: "Als beherrscht markieren", 171 | unmark_mastered: "Beherrschung aufheben", 172 | }, 173 | // KI-Wörterbuch-Fehler 174 | ai_errors: { 175 | word_empty: "Das Wort darf nicht leer sein", 176 | api_key_not_configured: "API-Schlüssel ist nicht konfiguriert. Bitte in den Plugin-Einstellungen festlegen", 177 | invalid_response: "Die API hat ein ungültiges Antwortformat zurückgegeben", 178 | api_key_invalid: "❌ API-Schlüssel ist ungültig oder abgelaufen. Bitte überprüfen Sie die Plugin-Einstellungen", 179 | rate_limit: "⏱️ API-Ratenlimit überschritten. Bitte versuchen Sie es später erneut", 180 | server_error: "🔧 API-Dienst ist vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut", 181 | network_error: "🌐 Netzwerkverbindung fehlgeschlagen. Bitte überprüfen Sie Ihre Netzwerkeinstellungen", 182 | request_failed: "KI-Wörterbuch-Anfrage fehlgeschlagen", 183 | }, 184 | } 185 | -------------------------------------------------------------------------------- /src/i18n/en.ts: -------------------------------------------------------------------------------- 1 | // English language pack 2 | 3 | export default { 4 | // General 5 | plugin_name: "HiWords", 6 | 7 | // Settings 8 | settings: { 9 | vocabulary_books: "Word books", 10 | add_vocabulary_book: "Add word book", 11 | remove_vocabulary_book: "Remove", 12 | show_definition_on_hover: "Show definition on hover", 13 | show_definition_on_hover_desc: "Show word definition when hovering over highlighted words", 14 | enable_auto_highlight: "Enable auto highlight", 15 | enable_auto_highlight_desc: "Automatically highlight words from vocabulary books while reading", 16 | highlight_style: "Highlight style", 17 | highlight_style_desc: "Choose how words are highlighted in text", 18 | style_underline: "Underline", 19 | style_background: "Background", 20 | style_bold: "Bold", 21 | style_dotted: "Dotted underline", 22 | style_wavy: "Wavy underline", 23 | save_settings: "Save settings", 24 | no_vocabulary_books: "No vocabulary books yet. Please add a Canvas file as a vocabulary book.", 25 | path: "Path", 26 | reload_book: "Reload this word book", 27 | statistics: "Statistics", 28 | total_books: "Total word books: {0}", 29 | enabled_books: "Enabled word books: {0}", 30 | total_words: "Total words: {0}", 31 | enable_mastered_feature: "Enable mastered feature", 32 | enable_mastered_feature_desc: "Mark mastered words to stop highlighting them. Sidebar shows grouped words", 33 | // Mastered detection mode 34 | mastered_detection: "Mastered detection mode", 35 | mastered_detection_desc: "Choose how to detect 'mastered': by group or by color (green = 4)", 36 | mode_group: "Group mode", 37 | mode_color: "Color mode (green = 4)", 38 | blur_definitions: "Blur content", 39 | blur_definitions_desc: "Blur content by default, reveal on hover. Great for learning—recall before seeing the answer", 40 | // TTS template 41 | tts_template: "TTS template", 42 | tts_template_desc: "Use {{word}} as placeholder, e.g. https://dict.youdao.com/dictvoice?audio={{word}}&type=2", 43 | ai_dictionary: "AI Assistant", 44 | ai_api_url: "API URL", 45 | ai_api_url_desc: "Full API endpoint (e.g., .../v1/chat/completions)", 46 | ai_api_key: "API Key", 47 | ai_api_key_desc: "Your AI API key", 48 | ai_model: "Model ID", 49 | ai_model_desc: "AI model identifier (e.g., gpt-4o-mini, deepseek-chat)", 50 | ai_prompt: "Custom Prompt", 51 | ai_prompt_desc: "Use {{word}} and {{sentence}} as placeholders. The AI will use this prompt to generate definitions", 52 | // Auto layout 53 | auto_layout: "Canvas auto layout", 54 | enable_auto_layout: "Enable auto layout", 55 | enable_auto_layout_desc: "Automatically normalize layout after add/update/delete and group changes", 56 | card_size: "Card size", 57 | card_size_desc: "Default card size (Width × Height, default: 260 × 120)", 58 | grid_gaps: "Grid gaps", 59 | grid_gaps_desc: "Horizontal/vertical gaps for the left grid", 60 | left_padding: "Left padding & min X", 61 | left_padding_desc: "Left padding from Mastered group and minimum X for the left area", 62 | columns_auto: "Auto columns", 63 | columns_auto_desc: "Auto determine columns (bounded by max columns)", 64 | columns: "Fixed columns / Max columns", 65 | columns_desc: "Specify fixed columns and max columns (effective when auto columns off)", 66 | group_inner_layout: "Group inner layout", 67 | group_inner_layout_desc: "Inner padding, gaps and columns for nodes inside groups", 68 | // Highlight scope settings 69 | highlight_scope: "Highlight scope", 70 | highlight_mode: "Highlight mode", 71 | highlight_mode_desc: "Choose the scope mode for word highlighting", 72 | mode_all: "All files", 73 | mode_exclude: "Exclude specified paths", 74 | mode_include: "Only specified paths", 75 | highlight_paths: "File paths", 76 | highlight_paths_desc: "Separate multiple paths with commas, supports folder paths (including subfolders)", 77 | highlight_paths_placeholder: "e.g.: Archive, Templates, Private/Diary", 78 | // File node parse mode 79 | file_node_parse_mode: "File node parse mode", 80 | file_node_parse_mode_desc: "Choose how to parse file cards in Canvas", 81 | mode_filename: "Filename only", 82 | mode_content: "File content only", 83 | mode_filename_with_alias: "Filename as word, content as alias", 84 | }, 85 | 86 | // Sidebar 87 | sidebar: { 88 | title: "HiWords", 89 | empty_state: "No words found. Add words to your vocabulary books to see them here.", 90 | source_prefix: "From: ", 91 | found: "Found", 92 | words: "words", 93 | no_definition: "No definition available.", 94 | vocabulary_book: "Words", 95 | mastered: "Mastered", 96 | no_learning_words: "No words to learn", 97 | no_mastered_words: "No mastered words", 98 | }, 99 | 100 | // Commands 101 | commands: { 102 | refresh_vocabulary: "Refresh vocabulary", 103 | add_word: "HiWords: Add word", 104 | edit_word: "HiWords: Edit word", 105 | show_sidebar: "Show HiWords sidebar", 106 | add_selected_word: "Add word", 107 | }, 108 | 109 | // Notices 110 | notices: { 111 | enter_word_first: "Please enter a word first", 112 | definition_fetched: "Definition fetched successfully", 113 | definition_fetch_failed: "Failed to fetch definition. Please check your network or try another word", 114 | vocabulary_refreshed: "Vocabulary books refreshed", 115 | word_added: "Word added to vocabulary book", 116 | word_exists: "Word already exists in vocabulary book", 117 | error_adding_word: "Error adding word to vocabulary book", 118 | select_book_required: "Please select a vocabulary book", 119 | adding_word: "Adding word to vocabulary book...", 120 | updating_word: "Updating word...", 121 | word_added_success: "Word \"{0}\" successfully added to vocabulary book", 122 | word_updated_success: "Word \"{0}\" successfully updated", 123 | add_word_failed: "Failed to add word, please check the vocabulary book file", 124 | update_word_failed: "Failed to update word, please check the vocabulary book file", 125 | error_processing_word: "Error processing word", 126 | no_canvas_files: "No Canvas files found", 127 | book_already_exists: "This vocabulary book already exists", 128 | invalid_canvas_file: "Invalid Canvas file", 129 | book_added: "Added vocabulary book: {0}", 130 | book_reloaded: "Reloaded vocabulary book: {0}", 131 | book_removed: "Removed vocabulary book: {0}", 132 | deleting_word: "Deleting word...", 133 | word_deleted: "Word deleted", 134 | delete_word_failed: "Failed to delete word, please check the vocabulary book file", 135 | error_deleting_word: "Error occurred while deleting word", 136 | mastered_feature_disabled: "Mastered feature is not enabled", 137 | update_word_status_failed: "Failed to update word status", 138 | move_to_mastered_group_failed: "Failed to move to mastered group", 139 | word_marked_as_mastered: "\"{0}\" marked as mastered", 140 | mark_mastered_failed: "Failed to mark as mastered, please try again", 141 | remove_from_mastered_group_failed: "Failed to remove from mastered group", 142 | word_unmarked_as_mastered: "\"{0}\" unmarked as mastered", 143 | no_text_selected: "Please select text to add as a word", 144 | word_required: "Please enter a word", 145 | unmark_mastered_failed: "Failed to unmark as mastered, please try again", 146 | batch_marked_success: "Successfully marked {0} words as mastered", 147 | book_path_updated: "Vocabulary book path updated: {0}", 148 | }, 149 | 150 | // Modals 151 | modals: { 152 | auto_fill_definition: "AI Define", 153 | word_label: "Word", 154 | word_placeholder: "Enter word to add...", 155 | definition_label: "Definition", 156 | book_label: "Vocabulary book", 157 | select_book: "Select a vocabulary book", 158 | color_label: "Card color", 159 | color_gray: "Gray", 160 | color_red: "Red", 161 | color_orange: "Orange", 162 | color_yellow: "Yellow", 163 | color_green: "Green", 164 | color_blue: "Blue", 165 | color_purple: "Purple", 166 | aliases_label: "Aliases (optional, comma separated)", 167 | aliases_placeholder: "e.g.: doing, done, did", 168 | definition_placeholder: "Enter word definition...", 169 | add_button: "Add", 170 | save_button: "Save", 171 | cancel_button: "Cancel", 172 | select_canvas_file: "Select vocabulary book file", 173 | delete_confirmation: "Are you sure you want to delete the word \"{0}\"?\nThis action cannot be undone.", 174 | }, 175 | // Common action labels 176 | actions: { 177 | expand: "Expand", 178 | collapse: "Collapse", 179 | mark_mastered: "Mark mastered", 180 | unmark_mastered: "Unmark mastered", 181 | }, 182 | // AI dictionary errors 183 | ai_errors: { 184 | word_empty: "Word cannot be empty", 185 | api_key_not_configured: "API Key is not configured. Please set it in the plugin settings", 186 | api_url_required: "API URL is required. Please set it in the plugin settings", 187 | model_required: "Model ID is required. Please set it in the plugin settings", 188 | prompt_required: "Custom prompt is required. Please set it in the plugin settings", 189 | invalid_api_url: "Invalid API URL format. Please check the URL in plugin settings", 190 | prompt_missing_word_placeholder: "Prompt must contain {{word}} placeholder", 191 | invalid_response: "API returned an invalid response format", 192 | api_key_invalid: "❌ API Key is invalid or expired. Please check plugin settings", 193 | rate_limit: "⏱️ API rate limit exceeded. Please try again later", 194 | server_error: "🔧 API service is temporarily unavailable. Please try again later", 195 | network_error: "🌐 Network connection failed. Please check your network settings", 196 | request_failed: "AI dictionary request failed", 197 | }, 198 | } 199 | -------------------------------------------------------------------------------- /src/services/dictionary-service.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from 'obsidian'; 2 | import { t } from '../i18n'; 3 | 4 | interface AIConfig { 5 | apiUrl: string; 6 | apiKey: string; 7 | model: string; 8 | prompt: string; 9 | } 10 | 11 | type APIType = 'openai' | 'claude' | 'gemini'; 12 | 13 | /** 14 | * API 配置接口 15 | */ 16 | interface APIAdapter { 17 | buildRequest: (model: string, prompt: string) => any; 18 | buildHeaders: (apiKey: string) => Record; 19 | extractResponse: (data: any) => string | undefined; 20 | buildUrl?: (baseUrl: string, model: string, apiKey: string) => string; 21 | } 22 | 23 | /** 24 | * 缓存条目 25 | */ 26 | interface CacheEntry { 27 | content: string; 28 | timestamp: number; 29 | } 30 | 31 | /** 32 | * 词典服务 - 使用 AI API(支持多种格式) 33 | */ 34 | export class DictionaryService { 35 | private config: AIConfig; 36 | private cache = new Map(); 37 | private readonly CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时 38 | private readonly MAX_RETRIES = 3; 39 | 40 | /** 41 | * API 适配器配置表 42 | */ 43 | private readonly API_ADAPTERS: Record = { 44 | openai: { 45 | buildRequest: (model: string, prompt: string) => ({ 46 | model, 47 | messages: [{ role: 'user', content: prompt }], 48 | temperature: 0.3, 49 | max_tokens: 500 50 | }), 51 | buildHeaders: (apiKey: string) => ({ 52 | 'Authorization': `Bearer ${apiKey}` 53 | }), 54 | extractResponse: (data: any) => data?.choices?.[0]?.message?.content 55 | }, 56 | claude: { 57 | buildRequest: (model: string, prompt: string) => ({ 58 | model, 59 | messages: [{ role: 'user', content: prompt }], 60 | max_tokens: 1024 61 | }), 62 | buildHeaders: (apiKey: string) => ({ 63 | 'x-api-key': apiKey, 64 | 'anthropic-version': '2023-06-01' 65 | }), 66 | extractResponse: (data: any) => data?.content?.[0]?.text 67 | }, 68 | gemini: { 69 | buildRequest: (model: string, prompt: string) => ({ 70 | contents: [{ parts: [{ text: prompt }] }] 71 | }), 72 | buildHeaders: () => ({}), 73 | extractResponse: (data: any) => data?.candidates?.[0]?.content?.parts?.[0]?.text, 74 | buildUrl: (baseUrl: string, model: string, apiKey: string) => { 75 | let url = baseUrl; 76 | if (!url.includes(':generateContent')) { 77 | url = `${url.replace(/\/$/, '')}/models/${model}:generateContent`; 78 | } 79 | return `${url}?key=${apiKey}`; 80 | } 81 | } 82 | }; 83 | 84 | constructor(config: AIConfig) { 85 | this.config = config; 86 | } 87 | 88 | /** 89 | * 自动检测 API 类型 90 | */ 91 | private detectAPIType(): APIType { 92 | const url = this.config.apiUrl.toLowerCase(); 93 | 94 | if (url.includes('anthropic')) { 95 | return 'claude'; 96 | } 97 | 98 | if (url.includes('googleapis') || url.includes('generativelanguage')) { 99 | return 'gemini'; 100 | } 101 | 102 | // 默认使用 OpenAI 兼容格式(支持大部分 API) 103 | return 'openai'; 104 | } 105 | 106 | /** 107 | * 验证 AI 配置是否有效 108 | */ 109 | private validateConfig(): { isValid: boolean; error?: string } { 110 | if (!this.config.apiUrl?.trim()) { 111 | return { isValid: false, error: t('ai_errors.api_url_required') }; 112 | } 113 | 114 | if (!this.config.apiKey?.trim()) { 115 | return { isValid: false, error: t('ai_errors.api_key_not_configured') }; 116 | } 117 | 118 | if (!this.config.model?.trim()) { 119 | return { isValid: false, error: t('ai_errors.model_required') }; 120 | } 121 | 122 | if (!this.config.prompt?.trim()) { 123 | return { isValid: false, error: t('ai_errors.prompt_required') }; 124 | } 125 | 126 | // 验证 URL 格式 127 | try { 128 | new URL(this.config.apiUrl); 129 | } catch { 130 | return { isValid: false, error: t('ai_errors.invalid_api_url') }; 131 | } 132 | 133 | // 验证 prompt 包含必要的占位符 134 | if (!this.config.prompt.includes('{{word}}')) { 135 | return { isValid: false, error: t('ai_errors.prompt_missing_word_placeholder') }; 136 | } 137 | 138 | return { isValid: true }; 139 | } 140 | 141 | /** 142 | * 替换 prompt 中的占位符 143 | */ 144 | private replacePlaceholders(word: string, sentence?: string): string { 145 | return this.config.prompt 146 | .replace(/\{\{word\}\}/g, word) 147 | .replace(/\{\{sentence\}\}/g, sentence || ''); 148 | } 149 | 150 | /** 151 | * 获取单词释义 152 | * @param word 要查询的单词 153 | * @param sentence 单词所在的句子(可选) 154 | * @returns 释义文本 155 | */ 156 | async fetchDefinition(word: string, sentence?: string): Promise { 157 | // 参数验证 158 | if (!word?.trim()) { 159 | throw new Error(t('ai_errors.word_empty')); 160 | } 161 | 162 | // 配置验证 163 | const validation = this.validateConfig(); 164 | if (!validation.isValid) { 165 | throw new Error(validation.error!); 166 | } 167 | 168 | const cleanWord = word.trim(); 169 | const cacheKey = `${cleanWord}:${sentence || ''}`; 170 | 171 | // 检查缓存 172 | const cached = this.cache.get(cacheKey); 173 | if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { 174 | return cached.content; 175 | } 176 | 177 | try { 178 | // 检测 API 类型并获取适配器 179 | const apiType = this.detectAPIType(); 180 | const adapter = this.API_ADAPTERS[apiType]; 181 | const prompt = this.replacePlaceholders(cleanWord, sentence); 182 | 183 | // 构建请求参数 184 | const url = adapter.buildUrl 185 | ? adapter.buildUrl(this.config.apiUrl, this.config.model, this.config.apiKey) 186 | : this.config.apiUrl; 187 | const headers = adapter.buildHeaders(this.config.apiKey); 188 | const body = adapter.buildRequest(this.config.model, prompt); 189 | 190 | // 发送请求(带重试) 191 | const data = await this.makeRequestWithRetry(url, headers, body); 192 | 193 | // 提取响应内容 194 | const content = adapter.extractResponse(data); 195 | if (!content) { 196 | throw new Error(t('ai_errors.invalid_response')); 197 | } 198 | 199 | const result = content.trim(); 200 | 201 | // 存入缓存 202 | this.cache.set(cacheKey, { content: result, timestamp: Date.now() }); 203 | 204 | return result; 205 | } catch (error) { 206 | throw this.handleError(error); 207 | } 208 | } 209 | 210 | /** 211 | * 发送 HTTP 请求(带重试) 212 | */ 213 | private async makeRequestWithRetry( 214 | url: string, 215 | headers: Record, 216 | body: any 217 | ): Promise { 218 | let lastError: any; 219 | 220 | for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) { 221 | try { 222 | const response = await requestUrl({ 223 | url, 224 | method: 'POST', 225 | headers: { 226 | 'Content-Type': 'application/json', 227 | ...headers 228 | }, 229 | body: JSON.stringify(body) 230 | }); 231 | 232 | if (response.status >= 400) { 233 | throw new Error(`HTTP ${response.status}: ${response.text}`); 234 | } 235 | 236 | return response.json; 237 | } catch (error) { 238 | lastError = error; 239 | 240 | // 如果是客户端错误(4xx),不重试 241 | const errorMsg = String(error); 242 | if (errorMsg.includes('400') || errorMsg.includes('401') || 243 | errorMsg.includes('403') || errorMsg.includes('404')) { 244 | break; 245 | } 246 | 247 | // 最后一次尝试,不等待 248 | if (attempt < this.MAX_RETRIES - 1) { 249 | // 指数退避: 1s, 2s, 4s 250 | const delay = 1000 * Math.pow(2, attempt); 251 | await new Promise(resolve => setTimeout(resolve, delay)); 252 | } 253 | } 254 | } 255 | 256 | throw lastError; 257 | } 258 | 259 | /** 260 | * 错误处理 - 转换为用户友好的错误信息 261 | */ 262 | private handleError(error: any): Error { 263 | const message = error?.message || String(error); 264 | 265 | // API Key 相关错误 266 | if (message.includes('401') || message.includes('403')) { 267 | return new Error(t('ai_errors.api_key_invalid')); 268 | } 269 | 270 | // 速率限制 271 | if (message.includes('429')) { 272 | return new Error(t('ai_errors.rate_limit')); 273 | } 274 | 275 | // 服务器错误 276 | if (message.includes('500') || message.includes('502') || 277 | message.includes('503') || message.includes('504')) { 278 | return new Error(t('ai_errors.server_error')); 279 | } 280 | 281 | // 网络错误 282 | if (message.includes('network') || message.includes('timeout')) { 283 | return new Error(t('ai_errors.network_error')); 284 | } 285 | 286 | // 其他错误 287 | console.error('AI Dictionary Error:', error); 288 | return new Error(`${t('ai_errors.request_failed')}: ${message}`); 289 | } 290 | 291 | /** 292 | * 清除缓存 293 | */ 294 | clearCache(): void { 295 | this.cache.clear(); 296 | } 297 | 298 | /** 299 | * 获取缓存统计 300 | */ 301 | getCacheStats(): { size: number; oldestEntry: number | null } { 302 | let oldestTimestamp: number | null = null; 303 | 304 | for (const entry of this.cache.values()) { 305 | if (oldestTimestamp === null || entry.timestamp < oldestTimestamp) { 306 | oldestTimestamp = entry.timestamp; 307 | } 308 | } 309 | 310 | return { 311 | size: this.cache.size, 312 | oldestEntry: oldestTimestamp 313 | }; 314 | } 315 | 316 | } 317 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Obsidian community plugin 2 | 3 | ## Project overview 4 | 5 | - Target: Obsidian Community Plugin (TypeScript → bundled JavaScript). 6 | - Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian. 7 | - Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`. 8 | 9 | ## Environment & tooling 10 | 11 | - Node.js: use current LTS (Node 18+ recommended). 12 | - **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies). 13 | - **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`. 14 | - Types: `obsidian` type definitions. 15 | 16 | **Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly. 17 | 18 | ### Install 19 | 20 | ```bash 21 | npm install 22 | ``` 23 | 24 | ### Dev (watch) 25 | 26 | ```bash 27 | npm run dev 28 | ``` 29 | 30 | ### Production build 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | ## Linting 37 | 38 | - To use eslint install eslint from terminal: `npm install -g eslint` 39 | - To use eslint to analyze this project use this command: `eslint main.ts` 40 | - eslint will then create a report with suggestions for code improvement by file and line number. 41 | - If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/` 42 | 43 | ## File & folder conventions 44 | 45 | - **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`. 46 | - Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands). 47 | - **Example file structure**: 48 | ``` 49 | src/ 50 | main.ts # Plugin entry point, lifecycle management 51 | settings.ts # Settings interface and defaults 52 | commands/ # Command implementations 53 | command1.ts 54 | command2.ts 55 | ui/ # UI components, modals, views 56 | modal.ts 57 | view.ts 58 | utils/ # Utility functions, helpers 59 | helpers.ts 60 | constants.ts 61 | types.ts # TypeScript interfaces and types 62 | ``` 63 | - **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control. 64 | - Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages. 65 | - Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`). 66 | 67 | ## Manifest rules (`manifest.json`) 68 | 69 | - Must include (non-exhaustive): 70 | - `id` (plugin ID; for local dev it should match the folder name) 71 | - `name` 72 | - `version` (Semantic Versioning `x.y.z`) 73 | - `minAppVersion` 74 | - `description` 75 | - `isDesktopOnly` (boolean) 76 | - Optional: `author`, `authorUrl`, `fundingUrl` (string or map) 77 | - Never change `id` after release. Treat it as stable API. 78 | - Keep `minAppVersion` accurate when using newer APIs. 79 | - Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml 80 | 81 | ## Testing 82 | 83 | - Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to: 84 | ``` 85 | /.obsidian/plugins// 86 | ``` 87 | - Reload Obsidian and enable the plugin in **Settings → Community plugins**. 88 | 89 | ## Commands & settings 90 | 91 | - Any user-facing commands should be added via `this.addCommand(...)`. 92 | - If the plugin has configuration, provide a settings tab and sensible defaults. 93 | - Persist settings using `this.loadData()` / `this.saveData()`. 94 | - Use stable command IDs; avoid renaming once released. 95 | 96 | ## Versioning & releases 97 | 98 | - Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version. 99 | - Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`. 100 | - Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets. 101 | - After the initial release, follow the process to add/update your plugin in the community catalog as required. 102 | 103 | ## Security, privacy, and compliance 104 | 105 | Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular: 106 | 107 | - Default to local/offline operation. Only make network requests when essential to the feature. 108 | - No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings. 109 | - Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases. 110 | - Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault. 111 | - Clearly disclose any external services used, data sent, and risks. 112 | - Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented. 113 | - Avoid deceptive patterns, ads, or spammy notifications. 114 | - Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely. 115 | 116 | ## UX & copy guidelines (for UI text, commands, settings) 117 | 118 | - Prefer sentence case for headings, buttons, and titles. 119 | - Use clear, action-oriented imperatives in step-by-step copy. 120 | - Use **bold** to indicate literal UI labels. Prefer "select" for interactions. 121 | - Use arrow notation for navigation: **Settings → Community plugins**. 122 | - Keep in-app strings short, consistent, and free of jargon. 123 | 124 | ## Performance 125 | 126 | - Keep startup light. Defer heavy work until needed. 127 | - Avoid long-running tasks during `onload`; use lazy initialization. 128 | - Batch disk access and avoid excessive vault scans. 129 | - Debounce/throttle expensive operations in response to file system events. 130 | 131 | ## Coding conventions 132 | 133 | - TypeScript with `"strict": true` preferred. 134 | - **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules. 135 | - **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules. 136 | - **Use clear module boundaries**: Each file should have a single, well-defined responsibility. 137 | - Bundle everything into `main.js` (no unbundled runtime deps). 138 | - Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly. 139 | - Prefer `async/await` over promise chains; handle errors gracefully. 140 | 141 | ## Mobile 142 | 143 | - Where feasible, test on iOS and Android. 144 | - Don't assume desktop-only behavior unless `isDesktopOnly` is `true`. 145 | - Avoid large in-memory structures; be mindful of memory and storage constraints. 146 | 147 | ## Agent do/don't 148 | 149 | **Do** 150 | - Add commands with stable IDs (don't rename once released). 151 | - Provide defaults and validation in settings. 152 | - Write idempotent code paths so reload/unload doesn't leak listeners or intervals. 153 | - Use `this.register*` helpers for everything that needs cleanup. 154 | 155 | **Don't** 156 | - Introduce network calls without an obvious user-facing reason and documentation. 157 | - Ship features that require cloud services without clear disclosure and explicit opt-in. 158 | - Store or transmit vault contents unless essential and consented. 159 | 160 | ## Common tasks 161 | 162 | ### Organize code across multiple files 163 | 164 | **main.ts** (minimal, lifecycle only): 165 | ```ts 166 | import { Plugin } from "obsidian"; 167 | import { MySettings, DEFAULT_SETTINGS } from "./settings"; 168 | import { registerCommands } from "./commands"; 169 | 170 | export default class MyPlugin extends Plugin { 171 | settings: MySettings; 172 | 173 | async onload() { 174 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 175 | registerCommands(this); 176 | } 177 | } 178 | ``` 179 | 180 | **settings.ts**: 181 | ```ts 182 | export interface MySettings { 183 | enabled: boolean; 184 | apiKey: string; 185 | } 186 | 187 | export const DEFAULT_SETTINGS: MySettings = { 188 | enabled: true, 189 | apiKey: "", 190 | }; 191 | ``` 192 | 193 | **commands/index.ts**: 194 | ```ts 195 | import { Plugin } from "obsidian"; 196 | import { doSomething } from "./my-command"; 197 | 198 | export function registerCommands(plugin: Plugin) { 199 | plugin.addCommand({ 200 | id: "do-something", 201 | name: "Do something", 202 | callback: () => doSomething(plugin), 203 | }); 204 | } 205 | ``` 206 | 207 | ### Add a command 208 | 209 | ```ts 210 | this.addCommand({ 211 | id: "your-command-id", 212 | name: "Do the thing", 213 | callback: () => this.doTheThing(), 214 | }); 215 | ``` 216 | 217 | ### Persist settings 218 | 219 | ```ts 220 | interface MySettings { enabled: boolean } 221 | const DEFAULT_SETTINGS: MySettings = { enabled: true }; 222 | 223 | async onload() { 224 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 225 | await this.saveData(this.settings); 226 | } 227 | ``` 228 | 229 | ### Register listeners safely 230 | 231 | ```ts 232 | this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ })); 233 | this.registerDomEvent(window, "resize", () => { /* ... */ }); 234 | this.registerInterval(window.setInterval(() => { /* ... */ }, 1000)); 235 | ``` 236 | 237 | ## Troubleshooting 238 | 239 | - Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `/.obsidian/plugins//`. 240 | - Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code. 241 | - Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique. 242 | - Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes. 243 | - Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust. 244 | 245 | ## References 246 | 247 | - Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin 248 | - API documentation: https://docs.obsidian.md 249 | - Developer policies: https://docs.obsidian.md/Developer+policies 250 | - Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines 251 | - Style guide: https://help.obsidian.md/style-guide 252 | -------------------------------------------------------------------------------- /src/canvas/canvas-editor.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from 'obsidian'; 2 | import { CanvasData, CanvasNode, HiWordsSettings } from '../utils'; 3 | import { CanvasParser } from './canvas-parser'; 4 | import { normalizeLayout } from './layout'; 5 | 6 | /** 7 | * Canvas 文件编辑器 8 | * 用于处理 Canvas 文件的修改操作 9 | */ 10 | export class CanvasEditor { 11 | private app: App; 12 | private settings: HiWordsSettings; 13 | 14 | constructor(app: App, settings: HiWordsSettings) { 15 | this.app = app; 16 | this.settings = settings; 17 | } 18 | 19 | updateSettings(settings: HiWordsSettings) { 20 | this.settings = settings; 21 | } 22 | 23 | /** 24 | * 生成 16 位十六进制小写 ID(贴近标准 Canvas ID 风格) 25 | */ 26 | private genHex16(): string { 27 | // 浏览器环境可用 crypto.getRandomValues 28 | const bytes = new Uint8Array(8); 29 | (window.crypto || (window as any).msCrypto).getRandomValues(bytes); 30 | return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); 31 | } 32 | 33 | /** 34 | * 添加词汇到 Canvas 文件 35 | * @param bookPath Canvas 文件路径 36 | * @param word 要添加的词汇 37 | * @param definition 词汇定义 38 | * @param color 可选的节点颜色 39 | * @param aliases 可选的词汇别名数组 40 | * @returns 操作是否成功 41 | */ 42 | /** 43 | * 添加词汇到 Canvas 文件 44 | * 优化版本:限制别名数量,添加错误处理 45 | * @returns 成功时返回生成的 nodeId,失败时返回 null 46 | */ 47 | async addWordToCanvas(bookPath: string, word: string, definition: string, color?: number, aliases?: string[]): Promise { 48 | try { 49 | const file = this.app.vault.getAbstractFileByPath(bookPath); 50 | if (!file || !(file instanceof TFile) || !CanvasParser.isCanvasFile(file)) { 51 | console.error(`无效的 Canvas 文件: ${bookPath}`); 52 | return null; 53 | } 54 | 55 | // 过滤空别名 56 | if (aliases) { 57 | aliases = aliases.filter((alias) => alias && alias.trim().length > 0); 58 | if (aliases.length === 0) aliases = undefined; 59 | } 60 | 61 | // 使用原子更新,避免并发覆盖 62 | const parser = new CanvasParser(this.app, this.settings); 63 | let generatedNodeId: string = ''; 64 | await this.app.vault.process(file, (current) => { 65 | const canvasData: CanvasData = JSON.parse(current || '{"nodes":[],"edges":[]}'); 66 | if (!Array.isArray(canvasData.nodes)) canvasData.nodes = []; 67 | 68 | // 生成 16-hex ID 69 | const nodeId = this.genHex16(); 70 | generatedNodeId = nodeId; 71 | 72 | // 放置参数(从设置中读取,如果未设置则使用默认值) 73 | const newW = this.settings.cardWidth ?? 260; 74 | const newH = this.settings.cardHeight ?? 120; 75 | const verticalGap = 20; 76 | const groupPadding = 24; // 与 Mastered 分组保持的水平间距 77 | 78 | // 简易几何工具(带兜底) 79 | const num = (v: any, def: number) => (typeof v === 'number' ? v : def); 80 | const rectOf = (n: Partial) => ({ 81 | x: num(n.x, 0), 82 | y: num(n.y, 0), 83 | w: num(n.width, 200), 84 | h: num(n.height, 60), 85 | }); 86 | const overlaps = (ax: number, aw: number, bx: number, bw: number) => ax < bx + bw && ax + aw > bx; 87 | 88 | // 定位 Mastered 分组(如果存在) 89 | const masteredGroup = canvasData.nodes.find( 90 | (n) => n.type === 'group' && (n as any).label && ((n as any).label === 'Mastered' || (n as any).label === '已掌握') 91 | ) as CanvasNode | undefined; 92 | const g = masteredGroup ? rectOf(masteredGroup as Partial) : undefined; 93 | 94 | // 计算位置:默认 (0,0)。优先选择“最后一个不在 Mastered 分组内的普通节点”作为参考 95 | let x = 0; 96 | let y = 0; 97 | if (canvasData.nodes.length > 0) { 98 | let ref: Partial | undefined; 99 | for (let i = canvasData.nodes.length - 1; i >= 0; i--) { 100 | const n = canvasData.nodes[i] as Partial; 101 | if (n.type === 'group') continue; 102 | if (g) { 103 | const r = rectOf(n); 104 | const insideHoriz = overlaps(r.x, r.w, g.x, g.w); 105 | const insideVert = overlaps(r.y, r.h, g.y, g.h); 106 | if (insideHoriz && insideVert) continue; // 跳过位于 Mastered 分组内的参考节点 107 | } 108 | ref = n; 109 | break; 110 | } 111 | if (ref) { 112 | const r = rectOf(ref); 113 | x = r.x; 114 | y = r.y + r.h + verticalGap; 115 | } 116 | } 117 | 118 | // 若新位置与 Mastered 分组水平范围相交,则将其放到分组右侧留白处 119 | if (g && overlaps(x, newW, g.x!, g.w!)) { 120 | x = g.x! + g.w! + groupPadding; 121 | } 122 | 123 | // 构建文本 124 | let nodeText = word; 125 | if (aliases && aliases.length > 0) nodeText = `${word}\n*${aliases.join(', ')}*`; 126 | if (definition) nodeText = `${nodeText}\n\n${definition}`; 127 | 128 | const newNode: CanvasNode = { 129 | id: nodeId, 130 | type: 'text', 131 | x, 132 | y, 133 | width: newW, 134 | height: newH, 135 | text: nodeText, 136 | color: color !== undefined ? color.toString() : undefined, 137 | }; 138 | 139 | canvasData.nodes.push(newNode); 140 | 141 | // 统一使用可配置的自动布局 142 | normalizeLayout(canvasData, this.settings, parser); 143 | return JSON.stringify(canvasData); 144 | }); 145 | 146 | return generatedNodeId; 147 | } catch (error) { 148 | console.error(`添加词汇到 Canvas 失败: ${error}`); 149 | return null; 150 | } 151 | } 152 | 153 | /** 154 | * 更新 Canvas 文件中的词汇 155 | * @param bookPath Canvas 文件路径 156 | * @param nodeId 要更新的节点ID 157 | * @param word 词汇 158 | * @param definition 词汇定义 159 | * @param color 可选的节点颜色 160 | * @param aliases 可选的词汇别名数组 161 | * @returns 操作是否成功 162 | */ 163 | async updateWordInCanvas(bookPath: string, nodeId: string, word: string, definition: string, color?: number, aliases?: string[]): Promise { 164 | try { 165 | const file = this.app.vault.getAbstractFileByPath(bookPath); 166 | if (!file || !(file instanceof TFile) || !CanvasParser.isCanvasFile(file)) { 167 | console.error(`无效的 Canvas 文件: ${bookPath}`); 168 | return false; 169 | } 170 | 171 | // 过滤空别名 172 | if (aliases) { 173 | aliases = aliases.filter((alias) => alias && alias.trim().length > 0); 174 | if (aliases.length === 0) aliases = undefined; 175 | } 176 | 177 | let updated = false; 178 | const parser = new CanvasParser(this.app); 179 | await this.app.vault.process(file, (current) => { 180 | const canvasData: CanvasData = JSON.parse(current || '{"nodes":[],"edges":[]}'); 181 | if (!Array.isArray(canvasData.nodes)) canvasData.nodes = []; 182 | 183 | const index = canvasData.nodes.findIndex((n) => n.id === nodeId); 184 | if (index === -1) { 185 | console.error(`未找到节点: ${nodeId}`); 186 | return JSON.stringify(canvasData); 187 | } 188 | 189 | let nodeText = word; 190 | if (aliases && aliases.length > 0) nodeText = `${word}\n*${aliases.join(', ')}*`; 191 | if (definition) nodeText = `${nodeText}\n\n${definition}`; 192 | 193 | canvasData.nodes[index].text = nodeText; 194 | if (color !== undefined) canvasData.nodes[index].color = color.toString(); 195 | 196 | // 自动布局 197 | normalizeLayout(canvasData, this.settings, parser); 198 | 199 | updated = true; 200 | return JSON.stringify(canvasData); 201 | }); 202 | 203 | return updated; 204 | } catch (error) { 205 | console.error(`更新 Canvas 中的词汇失败: ${error}`); 206 | return false; 207 | } 208 | } 209 | 210 | /** 211 | * 从 Canvas 文件中删除词汇 212 | * @param bookPath Canvas 文件路径 213 | * @param nodeId 要删除的节点ID 214 | * @returns 操作是否成功 215 | */ 216 | async deleteWordFromCanvas(bookPath: string, nodeId: string): Promise { 217 | try { 218 | const file = this.app.vault.getAbstractFileByPath(bookPath); 219 | if (!file || !(file instanceof TFile) || !CanvasParser.isCanvasFile(file)) { 220 | console.error(`无效的 Canvas 文件: ${bookPath}`); 221 | return false; 222 | } 223 | 224 | let removed = false; 225 | const parser = new CanvasParser(this.app); 226 | await this.app.vault.process(file, (current) => { 227 | const canvasData: CanvasData = JSON.parse(current || '{"nodes":[],"edges":[]}'); 228 | if (!Array.isArray(canvasData.nodes)) canvasData.nodes = []; 229 | 230 | const index = canvasData.nodes.findIndex((n) => n.id === nodeId); 231 | if (index === -1) { 232 | console.warn(`未找到要删除的节点: ${nodeId}`); 233 | return JSON.stringify(canvasData); 234 | } 235 | 236 | canvasData.nodes.splice(index, 1); 237 | // 自动布局 238 | normalizeLayout(canvasData, this.settings, parser); 239 | 240 | removed = true; 241 | return JSON.stringify(canvasData); 242 | }); 243 | 244 | return removed; 245 | } catch (error) { 246 | console.error(`从 Canvas 中删除词汇失败: ${error}`); 247 | return false; 248 | } 249 | } 250 | 251 | /** 252 | * 仅设置节点颜色(不修改文本、尺寸与位置) 253 | */ 254 | async setNodeColor(bookPath: string, nodeId: string, color?: number): Promise { 255 | try { 256 | const file = this.app.vault.getAbstractFileByPath(bookPath); 257 | if (!file || !(file instanceof TFile) || !CanvasParser.isCanvasFile(file)) { 258 | console.error(`无效的 Canvas 文件: ${bookPath}`); 259 | return false; 260 | } 261 | 262 | let updated = false; 263 | const parser = new CanvasParser(this.app, this.settings); 264 | await this.app.vault.process(file, (current) => { 265 | const canvasData: CanvasData = JSON.parse(current || '{"nodes":[],"edges":[]}'); 266 | if (!Array.isArray(canvasData.nodes)) canvasData.nodes = []; 267 | 268 | const index = canvasData.nodes.findIndex((n) => n.id === nodeId); 269 | if (index === -1) { 270 | console.error(`未找到节点: ${nodeId}`); 271 | return JSON.stringify(canvasData); 272 | } 273 | 274 | if (color !== undefined) { 275 | canvasData.nodes[index].color = color.toString(); 276 | } else { 277 | delete (canvasData.nodes[index] as any).color; 278 | } 279 | 280 | // 为保持布局一致性,仍调用一次规范化(轻量) 281 | normalizeLayout(canvasData, this.settings, parser); 282 | 283 | updated = true; 284 | return JSON.stringify(canvasData); 285 | }); 286 | 287 | return updated; 288 | } catch (error) { 289 | console.error(`设置节点颜色失败: ${error}`); 290 | return false; 291 | } 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/core/mastered-service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 已掌握单词服务 3 | * 负责处理单词的已掌握状态管理,包括标记、取消标记、状态同步等 4 | */ 5 | 6 | import { Notice } from 'obsidian'; 7 | import { VocabularyManager } from './vocabulary-manager'; 8 | import { MasteredGroupManager } from '../canvas'; 9 | import { t } from '../i18n'; 10 | import HiWordsPlugin from '../../main'; 11 | 12 | export class MasteredService { 13 | private plugin: HiWordsPlugin; 14 | private vocabularyManager: VocabularyManager; 15 | private masteredGroupManager: MasteredGroupManager; 16 | 17 | constructor(plugin: HiWordsPlugin, vocabularyManager: VocabularyManager) { 18 | this.plugin = plugin; 19 | this.vocabularyManager = vocabularyManager; 20 | this.masteredGroupManager = new MasteredGroupManager(plugin.app, plugin.settings); 21 | } 22 | 23 | updateSettings() { 24 | // 使用插件当前设置更新分组管理器 25 | this.masteredGroupManager.updateSettings(this.plugin.settings); 26 | } 27 | 28 | /** 29 | * 检查已掌握功能是否启用 30 | */ 31 | get isEnabled(): boolean { 32 | return this.plugin.settings.enableMasteredFeature; 33 | } 34 | 35 | /** 36 | * 标记单词为已掌握 37 | * @param bookPath 生词本路径 38 | * @param nodeId 节点 ID 39 | * @param word 单词文本 40 | * @returns 操作是否成功 41 | */ 42 | async markWordAsMastered(bookPath: string, nodeId: string, word: string): Promise { 43 | if (!this.isEnabled) { 44 | new Notice(t('notices.mastered_feature_disabled')); 45 | return false; 46 | } 47 | 48 | try { 49 | const mode = this.plugin.settings.masteredDetection ?? 'group'; 50 | 51 | // 1. 更新内存缓存中的已掌握状态 52 | const success = await this.updateWordMasteredStatus(bookPath, nodeId, true); 53 | if (!success) { 54 | new Notice(t('notices.update_word_status_failed')); 55 | return false; 56 | } 57 | 58 | // 2. 根据模式更新 Canvas 59 | if (mode === 'group') { 60 | const moveSuccess = await this.masteredGroupManager.moveToMasteredGroup(bookPath, nodeId); 61 | if (!moveSuccess) { 62 | // 如果移动失败,回滚内存状态 63 | await this.updateWordMasteredStatus(bookPath, nodeId, false); 64 | new Notice(t('notices.move_to_mastered_group_failed')); 65 | return false; 66 | } 67 | } else { 68 | // 颜色模式:设置为绿色(4) 69 | const colorSuccess = await this.vocabularyManager.setNodeColor(bookPath, nodeId, 4); 70 | if (!colorSuccess) { 71 | // 回滚内存状态 72 | await this.updateWordMasteredStatus(bookPath, nodeId, false); 73 | new Notice(t('notices.update_word_status_failed')); 74 | return false; 75 | } 76 | } 77 | 78 | // 3. 刷新高亮显示(排除已掌握单词) 79 | this.plugin.refreshHighlighter(); 80 | 81 | // 4. 触发侧边栏更新事件 82 | this.plugin.app.workspace.trigger('hi-words:mastered-changed'); 83 | 84 | // 5. 显示成功提示 85 | new Notice(t('notices.word_marked_as_mastered').replace('{0}', word)); 86 | 87 | return true; 88 | } catch (error) { 89 | console.error('标记已掌握失败:', error); 90 | new Notice(t('notices.mark_mastered_failed')); 91 | return false; 92 | } 93 | } 94 | 95 | /** 96 | * 取消单词的已掌握标记 97 | * @param bookPath 生词本路径 98 | * @param nodeId 节点 ID 99 | * @param word 单词文本 100 | * @returns 操作是否成功 101 | */ 102 | async unmarkWordAsMastered(bookPath: string, nodeId: string, word: string): Promise { 103 | if (!this.isEnabled) { 104 | new Notice(t('notices.mastered_feature_disabled')); 105 | return false; 106 | } 107 | 108 | try { 109 | const mode = this.plugin.settings.masteredDetection ?? 'group'; 110 | 111 | // 1. 更新内存缓存中的已掌握状态 112 | const success = await this.updateWordMasteredStatus(bookPath, nodeId, false); 113 | if (!success) { 114 | new Notice(t('notices.update_word_status_failed')); 115 | return false; 116 | } 117 | 118 | // 2. 根据模式更新 Canvas 119 | if (mode === 'group') { 120 | const removeSuccess = await this.masteredGroupManager.removeFromMasteredGroup(bookPath, nodeId); 121 | if (!removeSuccess) { 122 | // 如果移除失败,回滚内存状态 123 | await this.updateWordMasteredStatus(bookPath, nodeId, true); 124 | new Notice(t('notices.remove_from_mastered_group_failed')); 125 | return false; 126 | } 127 | } else { 128 | // 颜色模式:清除颜色 129 | const colorSuccess = await this.vocabularyManager.setNodeColor(bookPath, nodeId, undefined); 130 | if (!colorSuccess) { 131 | // 回滚内存状态 132 | await this.updateWordMasteredStatus(bookPath, nodeId, true); 133 | new Notice(t('notices.update_word_status_failed')); 134 | return false; 135 | } 136 | } 137 | 138 | // 3. 刷新高亮显示 139 | this.plugin.refreshHighlighter(); 140 | 141 | // 4. 触发侧边栏更新事件 142 | this.plugin.app.workspace.trigger('hi-words:mastered-changed'); 143 | 144 | // 5. 显示成功提示 145 | new Notice(t('notices.word_unmarked_as_mastered').replace('{0}', word)); 146 | 147 | return true; 148 | } catch (error) { 149 | console.error('取消已掌握标记失败:', error); 150 | new Notice(t('notices.unmark_mastered_failed')); 151 | return false; 152 | } 153 | } 154 | 155 | /** 156 | * 检查单词是否已掌握 157 | * @param bookPath 生词本路径 158 | * @param nodeId 节点 ID 159 | * @returns 是否已掌握 160 | */ 161 | async isWordMastered(bookPath: string, nodeId: string): Promise { 162 | if (!this.isEnabled) return false; 163 | 164 | try { 165 | const wordDef = await this.vocabularyManager.getWordDefinitionByNodeId(bookPath, nodeId); 166 | return wordDef?.mastered === true; 167 | } catch (error) { 168 | console.error('检查单词掌握状态失败:', error); 169 | return false; 170 | } 171 | } 172 | 173 | /** 174 | * 获取已掌握的单词列表 175 | * @param bookPath 生词本路径(可选,如果不提供则返回所有生词本的已掌握单词) 176 | * @returns 已掌握的单词定义数组 177 | */ 178 | async getMasteredWords(bookPath?: string) { 179 | if (!this.isEnabled) return []; 180 | 181 | try { 182 | const allWords = await this.vocabularyManager.getAllWordDefinitions(); 183 | 184 | return allWords.filter(wordDef => { 185 | // 过滤已掌握的单词 186 | if (!wordDef.mastered) return false; 187 | 188 | // 如果指定了生词本路径,只返回该生词本的单词 189 | if (bookPath && wordDef.source !== bookPath) return false; 190 | 191 | return true; 192 | }); 193 | } catch (error) { 194 | console.error('获取已掌握单词列表失败:', error); 195 | return []; 196 | } 197 | } 198 | 199 | /** 200 | * 获取已掌握单词的统计信息 201 | * @returns 统计信息对象 202 | */ 203 | async getMasteredStats() { 204 | if (!this.isEnabled) { 205 | return { 206 | totalMastered: 0, 207 | totalWords: 0, 208 | masteredPercentage: 0, 209 | byBook: {} 210 | }; 211 | } 212 | 213 | try { 214 | const allWords = await this.vocabularyManager.getAllWordDefinitions(); 215 | const masteredWords = allWords.filter(w => w.mastered); 216 | 217 | // 按生词本分组统计 218 | const byBook: { [bookPath: string]: { mastered: number, total: number } } = {}; 219 | 220 | allWords.forEach(word => { 221 | if (!byBook[word.source]) { 222 | byBook[word.source] = { mastered: 0, total: 0 }; 223 | } 224 | byBook[word.source].total++; 225 | if (word.mastered) { 226 | byBook[word.source].mastered++; 227 | } 228 | }); 229 | 230 | return { 231 | totalMastered: masteredWords.length, 232 | totalWords: allWords.length, 233 | masteredPercentage: allWords.length > 0 ? (masteredWords.length / allWords.length) * 100 : 0, 234 | byBook 235 | }; 236 | } catch (error) { 237 | console.error('获取已掌握统计信息失败:', error); 238 | return { 239 | totalMastered: 0, 240 | totalWords: 0, 241 | masteredPercentage: 0, 242 | byBook: {} 243 | }; 244 | } 245 | } 246 | 247 | /** 248 | * 批量标记多个单词为已掌握 249 | * @param operations 操作数组,每个操作包含 bookPath, nodeId, word 250 | * @returns 成功操作的数量 251 | */ 252 | async batchMarkAsMastered(operations: Array<{ bookPath: string, nodeId: string, word: string }>): Promise { 253 | if (!this.isEnabled) return 0; 254 | 255 | let successCount = 0; 256 | 257 | for (const op of operations) { 258 | const success = await this.markWordAsMastered(op.bookPath, op.nodeId, op.word); 259 | if (success) successCount++; 260 | } 261 | 262 | if (successCount > 0) { 263 | new Notice(t('notices.batch_marked_success').replace('{0}', successCount.toString())); 264 | } 265 | 266 | return successCount; 267 | } 268 | 269 | /** 270 | * 更新单词的已掌握状态(内存缓存) 271 | * @param bookPath 生词本路径 272 | * @param nodeId 节点 ID 273 | * @param mastered 是否已掌握 274 | * @returns 操作是否成功 275 | */ 276 | private async updateWordMasteredStatus(bookPath: string, nodeId: string, mastered: boolean): Promise { 277 | try { 278 | const wordDef = await this.vocabularyManager.getWordDefinitionByNodeId(bookPath, nodeId); 279 | if (!wordDef) { 280 | console.error(`未找到单词定义: ${nodeId}`); 281 | return false; 282 | } 283 | 284 | // 更新已掌握状态 285 | wordDef.mastered = mastered; 286 | 287 | // 通知词汇管理器更新缓存 288 | await this.vocabularyManager.updateWordDefinition(bookPath, nodeId, wordDef); 289 | 290 | return true; 291 | } catch (error) { 292 | console.error('更新单词掌握状态失败:', error); 293 | return false; 294 | } 295 | } 296 | 297 | /** 298 | * 同步 Canvas 分组状态与内存状态 299 | * 用于修复可能的不一致状态 300 | * @param bookPath 生词本路径 301 | */ 302 | async syncMasteredStatus(bookPath: string): Promise { 303 | if (!this.isEnabled) return; 304 | 305 | try { 306 | const allWords = await this.vocabularyManager.getWordDefinitionsByBook(bookPath); 307 | const mode = this.plugin.settings.masteredDetection ?? 'group'; 308 | 309 | for (const wordDef of allWords) { 310 | if (mode === 'group') { 311 | const inMasteredGroup = await this.masteredGroupManager.isNodeInMasteredGroup(bookPath, wordDef.nodeId); 312 | if (wordDef.mastered && !inMasteredGroup) { 313 | await this.masteredGroupManager.moveToMasteredGroup(bookPath, wordDef.nodeId); 314 | } else if (!wordDef.mastered && inMasteredGroup) { 315 | await this.masteredGroupManager.removeFromMasteredGroup(bookPath, wordDef.nodeId); 316 | } 317 | } else { 318 | // 颜色模式:以内存状态为准写回颜色 319 | if (wordDef.mastered) { 320 | await this.vocabularyManager.setNodeColor(bookPath, wordDef.nodeId, 4); 321 | } else { 322 | await this.vocabularyManager.setNodeColor(bookPath, wordDef.nodeId, undefined); 323 | } 324 | } 325 | } 326 | } catch (error) { 327 | console.error('同步已掌握状态失败:', error); 328 | } 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/canvas/mastered-group-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 已掌握分组管理器 3 | * 负责管理 Canvas 中的 Mastered 分组,包括创建、节点移动等操作 4 | */ 5 | 6 | import { App, TFile } from 'obsidian'; 7 | import { CanvasData, CanvasNode, HiWordsSettings } from '../utils'; 8 | import { CanvasParser } from './canvas-parser'; 9 | import { normalizeLayout, layoutGroupInner } from './layout'; 10 | 11 | export class MasteredGroupManager { 12 | private app: App; 13 | private canvasParser: CanvasParser; 14 | private settings: HiWordsSettings | undefined; 15 | private readonly MASTERED_GROUP_LABEL = 'Mastered'; 16 | private readonly MASTERED_GROUP_COLOR = '4'; // 绿色 17 | 18 | constructor(app: App, settings?: HiWordsSettings) { 19 | this.app = app; 20 | this.canvasParser = new CanvasParser(app); 21 | this.settings = settings; 22 | } 23 | 24 | updateSettings(settings: HiWordsSettings) { 25 | this.settings = settings; 26 | } 27 | 28 | /** 29 | * 生成 16 位十六进制小写 ID(贴近标准 Canvas ID 风格) 30 | */ 31 | private genHex16(): string { 32 | const bytes = new Uint8Array(8); 33 | (window.crypto || (window as any).msCrypto).getRandomValues(bytes); 34 | return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); 35 | } 36 | 37 | /** 38 | * 确保 Canvas 中存在 Mastered 分组,如果不存在则创建 39 | * @param bookPath Canvas 文件路径 40 | * @returns Mastered 分组的 ID 41 | */ 42 | async ensureMasteredGroup(bookPath: string): Promise { 43 | try { 44 | const canvasData = await this.loadCanvas(bookPath); 45 | if (!canvasData) return null; 46 | 47 | // 查找现有的 Mastered 分组 48 | let masteredGroup = canvasData.nodes.find( 49 | node => node.type === 'group' && node.label === this.MASTERED_GROUP_LABEL 50 | ); 51 | 52 | if (!masteredGroup) { 53 | // 需要创建新分组 54 | const newGroupId = this.genHex16(); 55 | 56 | await this.modifyCanvas(bookPath, (data) => { 57 | // 基于最新数据计算位置并创建分组 58 | const group = this.createMasteredGroup(data); 59 | group.id = newGroupId; // 使用预生成的 ID 60 | data.nodes.push(group); 61 | }); 62 | 63 | return newGroupId; 64 | } 65 | 66 | return masteredGroup.id; 67 | } catch (error) { 68 | return null; 69 | } 70 | } 71 | 72 | /** 73 | * 将单词节点移动到 Mastered 分组 74 | * @param bookPath Canvas 文件路径 75 | * @param nodeId 要移动的节点 ID 76 | * @returns 操作是否成功 77 | */ 78 | async moveToMasteredGroup(bookPath: string, nodeId: string): Promise { 79 | try { 80 | const masteredGroupId = await this.ensureMasteredGroup(bookPath); 81 | if (!masteredGroupId) return false; 82 | 83 | return await this.modifyCanvas(bookPath, (data) => { 84 | // 找到目标节点和分组 85 | const targetNode = data.nodes.find(node => node.id === nodeId); 86 | const masteredGroup = data.nodes.find(node => node.id === masteredGroupId); 87 | 88 | if (!targetNode || !masteredGroup) { 89 | return; 90 | } 91 | 92 | // 使用优化的两阶段定位方案 93 | const success = this.moveNodeToGroupOptimizedSync(targetNode, masteredGroup, data); 94 | if (!success) { 95 | return; 96 | } 97 | 98 | // 分组内布局与整体规范化 99 | try { 100 | if (this.settings) { 101 | layoutGroupInner(data, masteredGroup, this.settings, this.canvasParser); 102 | normalizeLayout(data, this.settings, this.canvasParser); 103 | } 104 | } catch {} 105 | }); 106 | } catch (error) { 107 | return false; 108 | } 109 | } 110 | 111 | /** 112 | * 从 Mastered 分组中移除单词节点 113 | * @param bookPath Canvas 文件路径 114 | * @param nodeId 要移除的节点 ID 115 | * @returns 操作是否成功 116 | */ 117 | async removeFromMasteredGroup(bookPath: string, nodeId: string): Promise { 118 | try { 119 | return await this.modifyCanvas(bookPath, (data) => { 120 | // 找到目标节点 121 | const targetNode = data.nodes.find(node => node.id === nodeId); 122 | if (!targetNode) return; 123 | 124 | // 找到 Mastered 分组 125 | const masteredGroup = data.nodes.find( 126 | node => node.type === 'group' && node.label === this.MASTERED_GROUP_LABEL 127 | ); 128 | if (!masteredGroup) return; 129 | 130 | // 注意:不再修改节点的group属性,改为通过坐标位置来判断分组关系 131 | 132 | // 将节点移动到分组外的合适位置 133 | this.moveNodeOutOfGroupSync(targetNode, data); 134 | 135 | // 整体规范化(不移动分组内的节点) 136 | try { 137 | if (this.settings) { 138 | normalizeLayout(data, this.settings, this.canvasParser); 139 | } 140 | } catch {} 141 | }); 142 | } catch (error) { 143 | return false; 144 | } 145 | } 146 | 147 | /** 148 | * 检查节点是否在 Mastered 分组中 149 | * @param bookPath Canvas 文件路径 150 | * @param nodeId 节点 ID 151 | * @returns 是否在分组中 152 | */ 153 | async isNodeInMasteredGroup(bookPath: string, nodeId: string): Promise { 154 | try { 155 | const canvasData = await this.loadCanvas(bookPath); 156 | if (!canvasData) return false; 157 | 158 | // 找到目标节点 159 | const targetNode = canvasData.nodes.find(node => node.id === nodeId); 160 | if (!targetNode) return false; 161 | 162 | // 找到 Mastered 分组 163 | const masteredGroup = canvasData.nodes.find( 164 | node => node.type === 'group' && node.label === this.MASTERED_GROUP_LABEL 165 | ); 166 | if (!masteredGroup) return false; 167 | 168 | // 使用坐标位置判断节点是否在分组内 169 | return this.canvasParser.isNodeInGroup(targetNode, masteredGroup); 170 | } catch (error) { 171 | return false; 172 | } 173 | } 174 | 175 | /** 176 | * 创建 Mastered 分组节点 177 | * @param canvasData Canvas 数据 178 | * @returns 新创建的分组节点 179 | */ 180 | private createMasteredGroup(canvasData: CanvasData): CanvasNode { 181 | // 使用 16-hex 小写 ID,避免与生态不一致 182 | const groupId = this.genHex16(); 183 | 184 | // 计算分组位置 185 | const { x, y } = this.calculateMasteredGroupPosition(canvasData); 186 | 187 | // 初始尺寸,后续会根据内容动态调整 188 | const initialWidth = 400; 189 | const initialHeight = 200; 190 | 191 | return { 192 | id: groupId, 193 | type: 'group', 194 | x: x, 195 | y: y, 196 | width: initialWidth, 197 | height: initialHeight, 198 | color: this.MASTERED_GROUP_COLOR, 199 | label: this.MASTERED_GROUP_LABEL 200 | }; 201 | } 202 | 203 | /** 204 | * 计算 Mastered 分组的位置 205 | * @param canvasData Canvas 数据 206 | * @returns 分组位置坐标 207 | */ 208 | private calculateMasteredGroupPosition(canvasData: CanvasData): { x: number, y: number } { 209 | // 找到所有节点(包括分组和文本节点) 210 | const allNodes = canvasData.nodes.filter(node => node.type === 'text' || node.type === 'group'); 211 | 212 | if (allNodes.length === 0) { 213 | return { x: 50, y: 50 }; // 默认位置 214 | } 215 | 216 | // 计算现有内容的边界 217 | const minX = Math.min(...allNodes.map(node => node.x)); 218 | const maxX = Math.max(...allNodes.map(node => node.x + (node.width || 200))); 219 | const minY = Math.min(...allNodes.map(node => node.y)); 220 | const maxY = Math.max(...allNodes.map(node => node.y + (node.height || 100))); 221 | 222 | // 尝试在右侧放置分组,如果空间不够则放在下方 223 | const groupWidth = 800; 224 | const groupHeight = 600; 225 | const padding = 50; 226 | 227 | // 先尝试右侧放置 228 | let x = maxX + padding; 229 | let y = minY; 230 | 231 | // 如果右侧空间不够,则放在下方 232 | if (x + groupWidth > 3000) { // 假设画布最大宽度为3000 233 | x = minX; 234 | y = maxY + padding; 235 | } 236 | 237 | return { x, y }; 238 | } 239 | 240 | /** 241 | * 优化的节点移动方案:两阶段定位 + 动态扩展(同步版本) 242 | * @param node 要移动的节点 243 | * @param group 目标分组 244 | * @param canvasData Canvas 数据 245 | * @returns 操作是否成功 246 | */ 247 | private moveNodeToGroupOptimizedSync( 248 | node: CanvasNode, 249 | group: CanvasNode, 250 | canvasData: CanvasData 251 | ): boolean { 252 | try { 253 | // 直接将节点粗放置到分组内部,最终布局交给 layoutGroupInner 254 | const padding = 24; 255 | const cardW = 260; 256 | const cardH = 120; 257 | node.width = node.width || cardW; 258 | node.height = node.height || cardH; 259 | node.x = Math.max(group.x + padding, group.x); 260 | node.y = Math.max(group.y + padding, group.y); 261 | return true; 262 | } catch { 263 | return false; 264 | } 265 | } 266 | 267 | /** 268 | * 将节点移动到分组外的合适位置(同步版本) 269 | * @param node 要移动的节点 270 | * @param canvasData Canvas 数据 271 | */ 272 | private moveNodeOutOfGroupSync(node: CanvasNode, canvasData: CanvasData): void { 273 | // 找到所有非分组节点(不在Mastered分组内的) 274 | const masteredGroup = canvasData.nodes.find( 275 | n => n.type === 'group' && n.label === this.MASTERED_GROUP_LABEL 276 | ); 277 | 278 | const freeTextNodes = canvasData.nodes.filter(n => { 279 | if (n.type !== 'text' || n.id === node.id) return false; 280 | if (!masteredGroup) return true; 281 | return !this.canvasParser.isNodeInGroup(n, masteredGroup); 282 | }); 283 | 284 | const paddingX = 50; 285 | const paddingY = 20; 286 | const nodeWidth = node.width || 260; 287 | const nodeHeight = node.height || 120; 288 | 289 | if (freeTextNodes.length === 0) { 290 | // 如果没有其他自由节点,放在默认位置 291 | node.x = paddingX; 292 | node.y = paddingY; 293 | return; 294 | } 295 | 296 | // 简化:放到当前自由节点的下方一行,由 normalizeLayout 统一整理 297 | const minX = Math.min(...freeTextNodes.map(n => n.x), paddingX); 298 | const maxY = Math.max(...freeTextNodes.map(n => n.y + (n.height || nodeHeight)), 0); 299 | node.x = minX; 300 | node.y = maxY + paddingY; 301 | 302 | // 确保不与分组重叠 303 | if (masteredGroup) { 304 | const groupBottom = masteredGroup.y + masteredGroup.height; 305 | if (node.y < groupBottom + paddingY) { 306 | node.y = groupBottom + paddingY; 307 | } 308 | } 309 | 310 | 311 | } 312 | 313 | 314 | 315 | /** 316 | * 获取分组内的成员节点 317 | */ 318 | private getGroupMembers(group: CanvasNode, canvasData: CanvasData, excludeNodeId?: string): CanvasNode[] { 319 | return canvasData.nodes.filter(n => 320 | n.id !== group.id && 321 | n.id !== excludeNodeId && 322 | n.type !== 'group' && 323 | this.canvasParser.isNodeInGroup(n, group) 324 | ); 325 | } 326 | 327 | /** 328 | * 加载 Canvas 文件数据 329 | * @param bookPath Canvas 文件路径 330 | * @returns Canvas 数据或 null 331 | */ 332 | private async loadCanvas(bookPath: string): Promise { 333 | try { 334 | const file = this.app.vault.getAbstractFileByPath(bookPath); 335 | if (!(file instanceof TFile)) { 336 | return null; 337 | } 338 | 339 | const content = await this.app.vault.cachedRead(file); 340 | return JSON.parse(content) as CanvasData; 341 | } catch (error) { 342 | return null; 343 | } 344 | } 345 | 346 | /** 347 | * 原子性修改 Canvas 文件 348 | * @param bookPath Canvas 文件路径 349 | * @param modifier 修改函数,接收最新的 Canvas 数据并进行修改 350 | * @returns 操作是否成功 351 | */ 352 | private async modifyCanvas( 353 | bookPath: string, 354 | modifier: (data: CanvasData) => void 355 | ): Promise { 356 | try { 357 | const file = this.app.vault.getAbstractFileByPath(bookPath); 358 | if (!(file instanceof TFile)) { 359 | return false; 360 | } 361 | 362 | // 使用原子更新,基于最新内容进行修改 363 | await this.app.vault.process(file, (current) => { 364 | const data = JSON.parse(current) as CanvasData; 365 | modifier(data); // 应用修改 366 | return JSON.stringify(data); 367 | }); 368 | return true; 369 | } catch (error) { 370 | return false; 371 | } 372 | } 373 | 374 | 375 | } 376 | -------------------------------------------------------------------------------- /src/canvas/canvas-parser.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from 'obsidian'; 2 | import { CanvasData, CanvasNode, WordDefinition, HiWordsSettings } from '../utils'; 3 | import { parsePhrase } from '../utils/pattern-matcher'; 4 | 5 | export class CanvasParser { 6 | private app: App; 7 | private settings?: HiWordsSettings; 8 | 9 | constructor(app: App, settings?: HiWordsSettings) { 10 | this.app = app; 11 | this.settings = settings; 12 | } 13 | 14 | updateSettings(settings: HiWordsSettings) { 15 | this.settings = settings; 16 | } 17 | 18 | /** 19 | * 去除文本中的 Markdown 格式符号 20 | * @param text 要处理的文本 21 | * @returns 处理后的文本 22 | */ 23 | private removeMarkdownFormatting(text: string): string { 24 | if (!text) return text; 25 | 26 | // 去除加粗格式 **text** 或 __text__ 27 | text = text.replace(/\*\*(.*?)\*\*/g, '$1').replace(/__(.*?)__/g, '$1'); 28 | 29 | // 去除斜体格式 *text* 或 _text_ 30 | text = text.replace(/\*(.*?)\*/g, '$1').replace(/_(.*?)_/g, '$1'); 31 | 32 | // 去除行内代码格式 `text` 33 | text = text.replace(/`(.*?)`/g, '$1'); 34 | 35 | // 去除删除线格式 ~~text~~ 36 | text = text.replace(/~~(.*?)~~/g, '$1'); 37 | 38 | // 去除高亮格式 ==text== 39 | text = text.replace(/==(.*?)==/g, '$1'); 40 | 41 | // 去除链接格式 [text](url) 42 | text = text.replace(/\[(.*?)\]\(.*?\)/g, '$1'); 43 | 44 | return text.trim(); 45 | } 46 | 47 | /** 48 | * 去除 Markdown 文本开头的 Frontmatter(YAML) 49 | * 仅在文本以 --- 开头时尝试移除首个 frontmatter 块 50 | */ 51 | private removeFrontmatter(text: string): string { 52 | if (!text) return text; 53 | // 去除 BOM 54 | if (text.charCodeAt(0) === 0xFEFF) { 55 | text = text.slice(1); 56 | } 57 | // 仅当以 --- 开头时尝试剥离到下一处 --- 为止 58 | if (text.startsWith('---')) { 59 | const fmEnd = text.indexOf('\n---'); 60 | if (fmEnd !== -1) { 61 | const after = text.slice(fmEnd + 4); // 跳过 "\n---" 62 | // 去掉可能紧随其后的一个换行 63 | return after.replace(/^\r?\n/, ''); 64 | } 65 | } 66 | return text; 67 | } 68 | 69 | /** 70 | * 解析 Canvas 文件,提取词汇定义 71 | */ 72 | async parseCanvasFile(file: TFile): Promise { 73 | try { 74 | const content = await this.app.vault.cachedRead(file); 75 | const canvasData: CanvasData = JSON.parse(content); 76 | 77 | const detectionMode = this.settings?.masteredDetection ?? 'group'; 78 | // 查找 "Mastered" 分组(当使用分组模式时) 79 | const masteredGroup = detectionMode === 'group' 80 | ? canvasData.nodes.find(node => 81 | node.type === 'group' && 82 | (node.label === 'Mastered' || node.label === '已掌握') 83 | ) 84 | : undefined; 85 | 86 | const definitions: WordDefinition[] = []; 87 | 88 | for (const node of canvasData.nodes) { 89 | // 文本节点 90 | if (node.type === 'text' && node.text) { 91 | const wordDef = this.parseTextNode(node, file.path); 92 | if (wordDef) { 93 | if (detectionMode === 'group') { 94 | if (masteredGroup && this.isNodeInGroup(node, masteredGroup)) { 95 | wordDef.mastered = true; 96 | } 97 | } else if (detectionMode === 'color') { 98 | if (node.color === '4') { 99 | wordDef.mastered = true; 100 | } 101 | } 102 | definitions.push(wordDef); 103 | } 104 | } 105 | // 文件节点(Markdown) 106 | else if (node.type === 'file' && (node as any).file) { 107 | const wordDef = await this.parseFileNode(node, file.path); 108 | if (wordDef) { 109 | if (detectionMode === 'group') { 110 | if (masteredGroup && this.isNodeInGroup(node, masteredGroup)) { 111 | wordDef.mastered = true; 112 | } 113 | } else if (detectionMode === 'color') { 114 | if (node.color === '4') { 115 | wordDef.mastered = true; 116 | } 117 | } 118 | definitions.push(wordDef); 119 | } 120 | } 121 | } 122 | 123 | return definitions; 124 | } catch (error) { 125 | console.error(`Failed to parse canvas file ${file.path}:`, error); 126 | return []; 127 | } 128 | } 129 | 130 | /** 131 | * 从任意文本内容解析单词、别名和定义 132 | */ 133 | private parseFromText(text: string, node: CanvasNode, sourcePath: string): WordDefinition | null { 134 | if (!text) return null; 135 | 136 | // 先移除 Frontmatter,再整体修剪 137 | text = this.removeFrontmatter(text).trim(); 138 | let word = ''; 139 | let aliases: string[] = []; 140 | let definition = ''; 141 | 142 | try { 143 | // 分割文本行 144 | const lines = text.split('\n'); 145 | if (lines.length === 0) return null; 146 | 147 | // 获取第一行作为主词 148 | word = lines[0].replace(/^#+\s*/, '').trim(); 149 | 150 | // 去除 Markdown 格式符号(加粗、斜体、代码块等) 151 | word = this.removeMarkdownFormatting(word); 152 | 153 | if (!word) return null; 154 | 155 | // 处理别名和定义 156 | if (lines.length > 1) { 157 | // 循环查找斜体别名行 158 | let aliasLineIndex = -1; 159 | let definitionStartIndex = -1; 160 | // 解析别名(第二行开始) 161 | for (let i = 1; i < lines.length; i++) { 162 | const line = lines[i].trim(); 163 | 164 | // 检查斜体格式别名 *alias1, alias2, alias3* 165 | if (line.startsWith('*') && line.endsWith('*') && line.length > 2) { 166 | const aliasText = line.slice(1, -1); // 去掉首尾的 * 167 | const aliasArray = aliasText.split(',').map(a => a.trim()).filter(a => a); 168 | aliases.push(...aliasArray); 169 | aliasLineIndex = i; 170 | } 171 | // 如果不是别名行且不是空行,则这是定义的开始 172 | else if (line !== '') { 173 | definitionStartIndex = i; 174 | break; 175 | } 176 | } 177 | 178 | // 如果找到了定义的开始,则提取定义 179 | if (definitionStartIndex > 0 && definitionStartIndex < lines.length) { 180 | // 跳过定义开始的空行 181 | while (definitionStartIndex < lines.length && lines[definitionStartIndex].trim() === '') { 182 | definitionStartIndex++; 183 | } 184 | 185 | if (definitionStartIndex < lines.length) { 186 | definition = lines.slice(definitionStartIndex).join('\n').trim(); 187 | } 188 | } else if (aliasLineIndex === -1) { 189 | // 如果没有找到别名行,则所有后续行都是定义 190 | definition = lines.slice(1).join('\n').trim(); 191 | } 192 | } 193 | 194 | if (!word) return null; 195 | 196 | // 解析短语,检测是否为模式短语 197 | const phraseInfo = parsePhrase(word); 198 | 199 | const result: WordDefinition = { 200 | word: phraseInfo.isPattern ? phraseInfo.original : word.toLowerCase(), // 模式短语保持原样,普通单词转小写 201 | aliases: aliases.length > 0 ? aliases : undefined, 202 | definition, 203 | source: sourcePath, 204 | nodeId: node.id, 205 | color: node.color, 206 | isPattern: phraseInfo.isPattern, 207 | patternParts: phraseInfo.isPattern ? phraseInfo.parts : undefined 208 | }; 209 | 210 | 211 | 212 | return result; 213 | } catch (error) { 214 | console.error(`解析节点文本时出错: ${error}`); 215 | return null; 216 | } 217 | } 218 | 219 | /** 220 | * 解析文本节点,提取单词、别名和定义(包装通用文本解析) 221 | * 优化版本:支持主名字换行后的斜体格式作为别名格式 222 | */ 223 | private parseTextNode(node: CanvasNode, sourcePath: string): WordDefinition | null { 224 | if (!node.text) return null; 225 | return this.parseFromText(node.text, node, sourcePath); 226 | } 227 | 228 | /** 229 | * 解析文件节点(Markdown),根据配置选择解析模式 230 | */ 231 | private async parseFileNode(node: CanvasNode, sourcePath: string): Promise { 232 | try { 233 | const filePath = (node as any).file as string | undefined; 234 | if (!filePath) return null; 235 | 236 | const abs = this.app.vault.getAbstractFileByPath(filePath); 237 | if (!(abs instanceof TFile)) return null; 238 | if (abs.extension !== 'md') return null; 239 | 240 | const mode = this.settings?.fileNodeParseMode || 'filename-with-alias'; 241 | const fileName = abs.basename; // 文件名(不含扩展名) 242 | 243 | // 模式 1:仅使用文件名 244 | if (mode === 'filename') { 245 | return { 246 | word: fileName, 247 | aliases: [], 248 | definition: '', 249 | source: sourcePath, 250 | nodeId: node.id, 251 | color: node.color, 252 | mastered: false 253 | }; 254 | } 255 | 256 | // 读取文件内容 257 | const md = await this.app.vault.cachedRead(abs); 258 | 259 | // 模式 2:仅使用文件内容(当前行为) 260 | if (mode === 'content') { 261 | return this.parseFromText(md, node, sourcePath); 262 | } 263 | 264 | // 模式 3:文件名作为主词,内容第一行作为别名(默认) 265 | if (mode === 'filename-with-alias') { 266 | const parsed = this.parseFromText(md, node, sourcePath); 267 | 268 | if (parsed && parsed.word) { 269 | // 将原来的 word 作为别名 270 | const originalWord = parsed.word; 271 | const existingAliases = parsed.aliases || []; 272 | const newAliases = originalWord !== fileName 273 | ? [originalWord, ...existingAliases] 274 | : existingAliases; // 如果文件名和内容第一行相同,避免重复 275 | 276 | return { 277 | ...parsed, 278 | word: fileName, 279 | aliases: newAliases 280 | }; 281 | } 282 | 283 | // 如果解析失败(文件为空),至少返回文件名 284 | return { 285 | word: fileName, 286 | aliases: [], 287 | definition: '', 288 | source: sourcePath, 289 | nodeId: node.id, 290 | color: node.color, 291 | mastered: false 292 | }; 293 | } 294 | 295 | return null; 296 | } catch (error) { 297 | console.error('解析文件节点失败:', error); 298 | return null; 299 | } 300 | } 301 | 302 | /** 303 | * 检查文件是否为 Canvas 文件 304 | */ 305 | static isCanvasFile(file: TFile): boolean { 306 | return file.extension === 'canvas'; 307 | } 308 | 309 | /** 310 | * 验证 Canvas 文件格式 311 | */ 312 | async validateCanvasFile(file: TFile): Promise { 313 | try { 314 | const content = await this.app.vault.cachedRead(file); 315 | const trimmed = content?.trim() ?? ''; 316 | // 新建但尚未写入内容的空 Canvas 也视为有效 317 | if (trimmed === '') return true; 318 | 319 | const data = JSON.parse(trimmed); 320 | // 若字段缺失,视为默认空数组也有效 321 | const nodesOk = !('nodes' in data) || Array.isArray(data.nodes); 322 | const edgesOk = !('edges' in data) || Array.isArray(data.edges); 323 | return nodesOk && edgesOk; 324 | } catch { 325 | // 解析失败,但既然是 .canvas 文件,允许添加,后续解析将返回空结果 326 | return true; 327 | } 328 | } 329 | 330 | /** 331 | * 检查节点是否在指定分组内 332 | * @param node 要检查的节点 333 | * @param group 分组节点 334 | * @returns 是否在分组内 335 | */ 336 | public isNodeInGroup(node: CanvasNode, group: CanvasNode): boolean { 337 | // 仅使用几何判定,避免与 node.group 字段产生二义性 338 | const nodeX = typeof node.x === 'number' ? node.x : 0; 339 | const nodeY = typeof node.y === 'number' ? node.y : 0; 340 | const nodeW = typeof node.width === 'number' ? node.width : 200; // 文本默认宽 341 | const nodeH = typeof node.height === 'number' ? node.height : 60; // 文本默认高 342 | 343 | const groupX = typeof group.x === 'number' ? group.x : 0; 344 | const groupY = typeof group.y === 'number' ? group.y : 0; 345 | const groupW = typeof group.width === 'number' ? group.width : 300; // 分组默认宽 346 | const groupH = typeof group.height === 'number' ? group.height : 150; // 分组默认高 347 | 348 | const nodeLeft = nodeX; 349 | const nodeRight = nodeX + nodeW; 350 | const nodeTop = nodeY; 351 | const nodeBottom = nodeY + nodeH; 352 | 353 | const groupLeft = groupX; 354 | const groupRight = groupX + groupW; 355 | const groupTop = groupY; 356 | const groupBottom = groupY + groupH; 357 | 358 | const isInside = 359 | nodeLeft >= groupLeft && 360 | nodeRight <= groupRight && 361 | nodeTop >= groupTop && 362 | nodeBottom <= groupBottom; 363 | 364 | if (!isInside) { 365 | const hasOverlap = 366 | nodeLeft < groupRight && 367 | nodeRight > groupLeft && 368 | nodeTop < groupBottom && 369 | nodeBottom > groupTop; 370 | 371 | if (hasOverlap) { 372 | const overlapLeft = Math.max(nodeLeft, groupLeft); 373 | const overlapRight = Math.min(nodeRight, groupRight); 374 | const overlapTop = Math.max(nodeTop, groupTop); 375 | const overlapBottom = Math.min(nodeBottom, groupBottom); 376 | const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop); 377 | const nodeArea = nodeW * nodeH; 378 | return overlapArea >= nodeArea * 0.5; 379 | } 380 | return false; 381 | } 382 | 383 | return true; 384 | } 385 | } 386 | --------------------------------------------------------------------------------