diff --git a/.gitignore b/.gitignore index b191ef4a..178528a6 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ ffmpeg-core.worker.js # dict cam_dict.refined.sqlite +enjoy/lib/dictionaries .vitepress/cache/ public/jupyter-notebooks/*.mp3 diff --git a/enjoy/package.json b/enjoy/package.json index 63503b23..f0fe16b2 100644 --- a/enjoy/package.json +++ b/enjoy/package.json @@ -54,9 +54,11 @@ "@types/mark.js": "^8.11.12", "@types/mustache": "^4.2.5", "@types/node": "^22.5.0", + "@types/prop-types": "^15", "@types/rails__actioncable": "^6.1.11", "@types/react": "^18.3.4", "@types/react-dom": "^18.3.0", + "@types/unzipper": "^0", "@types/validator": "^13.12.1", "@types/wavesurfer.js": "^6.0.12", "@typescript-eslint/eslint-plugin": "^8.2.0", @@ -72,6 +74,7 @@ "flora-colossus": "^2.0.0", "octokit": "^4.0.2", "progress": "^2.0.3", + "prop-types": "^15.8.1", "tailwind-merge": "^2.5.2", "tailwind-scrollbar": "^3.1.0", "tailwindcss": "^3.4.10", @@ -151,6 +154,7 @@ "js-md5": "^0.8.3", "langchain": "^0.2.17", "lodash": "^4.17.21", + "lru-cache": "^11.0.0", "lucide-react": "^0.436.0", "mark.js": "^8.11.1", "microsoft-cognitiveservices-speech-sdk": "^1.40.0", @@ -165,12 +169,14 @@ "react-audio-visualize": "^1.1.3", "react-audio-voice-recorder": "^2.2.0", "react-dom": "^18.3.1", + "react-frame-component": "^5.2.7", "react-hook-form": "^7.53.0", "react-hotkeys-hook": "^4.5.0", "react-i18next": "^15.0.1", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.1", "react-router-dom": "^6.26.1", + "react-shadow-root": "^6.2.0", "react-tooltip": "^5.28.0", "reflect-metadata": "^0.2.2", "rimraf": "^6.0.1", @@ -180,6 +186,7 @@ "sqlite3": "^5.1.7", "tailwind-scrollbar-hide": "^1.1.7", "umzug": "^3.8.1", + "unzipper": "^0.12.3", "update-electron-app": "^3.0.0", "wavesurfer.js": "^7.8.4", "zod": "^3.23.8", diff --git a/enjoy/src/constants/dicts.ts b/enjoy/src/constants/dicts.ts new file mode 100644 index 00000000..d3c9ae50 --- /dev/null +++ b/enjoy/src/constants/dicts.ts @@ -0,0 +1,68 @@ +export const DICTS = [ + { + name: "ldoce5", + fileName: "ldoce5.zip", + title: "Longman Dictionary of Contemporary English", + pronunciation: true, + lang: "En-En", + downloadUrl: "https://dl.enjoy.bot/dicts/ldoce5.zip", + size: "1.63GB", + hash: "4a03ce291ea7b6e0ea46f4c2fc335ad4", + addition: '', + }, + { + name: "oxford_en_mac", + fileName: "oxford_en_mac.zip", + title: "Oxford Dictionary of English (Mac)", + pronunciation: false, + lang: "En-En", + downloadUrl: "https://dl.enjoy.bot/dicts/oxford_en_mac.zip", + hash: "cffaef4b3ed6ec7d3ee7209b18e05c6f", + size: "33.6MB", + addition: '', + }, + { + name: "koen_mac", + fileName: "koen_mac.zip", + title: "Korean English Dictionary (Mac)", + pronunciation: false, + lang: "Ko-En", + downloadUrl: "https://dl.enjoy.bot/dicts/koen_mac.zip", + size: "52.1MB", + hash: "fa028c585de10e54a7028c6683738499", + addition: '', + }, + { + name: "jaen_mac", + fileName: "jaen_mac.zip", + title: "Sanseido The WISDOM English-Japanese Japanese-English Dictionary", + pronunciation: false, + lang: "Ja-En", + downloadUrl: "https://dl.enjoy.bot/dicts/jaen_mac.zip", + hash: "3008e1cd2a8b6f224f90d14a8e1de9cb", + size: "39.8MB", + addition: '', + }, + { + name: "deen_mac", + fileName: "deen_mac.zip", + title: "German English Dictionary (Mac)", + pronunciation: false, + lang: "Ge-En", + downloadUrl: "https://dl.enjoy.bot/dicts/deen_mac.zip", + hash: "3fedde07108236f6e6cfe907bd60faba", + size: "32.1MB", + addition: '', + }, + { + name: "ruen_mac", + fileName: "ruen_mac.zip", + title: "Russian English Dictionary (Mac)", + pronunciation: false, + lang: "Ru-En", + downloadUrl: "https://dl.enjoy.bot/dicts/ruen_mac.zip", + hash: "5b98fc0e5c3de9df43189cb79d5bf4cc", + size: "18.1MB", + addition: '', + }, +]; diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index 3c0e3d37..0ed5e3a8 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -743,5 +743,31 @@ "recordingIsTooLongToAssess": "Recording is too long to assess. The maximum duration is 60 seconds.", "recorderConfig": "Recorder config", "recorderConfigSaved": "Recorder config saved", - "recorderConfigDescription": "Advanced settings for recorder" + "recorderConfigDescription": "Advanced settings for recorder", + "lookupOnMouseOver": "Lookup On MouseOver", + "selectDictFile": "Select Dict Files (extension with .mdx and .mdd files)", + "dictFiles": "Dict Files", + "dictFileRequired": "Dict file (the extension is mdx) not found.", + "dictFileExist": "{{name}} has already been imported", + "dictFileAddSuccess": "Add {{name}} successfully", + "dictFileRemoveSuccess": "Remove {{name}} successfully", + "dictFileSetDefaultSuccess": "Set default successfully", + "dictionaries": "Dictionaries", + "import": "Import", + "default": "Default", + "setDefault": "Set Default", + "dictSettings": "Dict Settings", + "dictSettingsShort": "Dict Settings", + "importDict": "Import Dictionary", + "dictRemoved": "Dictionary removed successfully", + "interrupted": "Interrupted", + "paused": "Paused", + "completedAndChecking": "Completed (Checking)", + "decompressing": "Decompressing", + "resume": "Resume", + "dictEmpty": "No dictionaries imported", + "removing": "Removing", + "removeDictTitle": "Are you sure you want to delete this dictionary? ", + "removeDictDescription": "It will delete the dictionary file from your local computer and you will have to download it again next time.", + "downloadingDict": "Downloading" } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index d2581463..8f614180 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -743,5 +743,31 @@ "recordingIsTooLongToAssess": "录音时长过长,无法评估。最长支持 1 分钟。", "recorderConfig": "录音设置", "recorderConfigSaved": "录音设置已保存", - "recorderConfigDescription": "调整录音高级设置" + "recorderConfigDescription": "调整录音高级设置", + "lookupOnMouseOver": "鼠标悬停查询单词", + "selectDictFile": "选择字典文件 (扩展名为 .mdx 和 .mdd 的文件)", + "dictFiles": "字典文件", + "dictFileRequired": "未找到字典文件 (扩展名为 .mdx 的文件是必须的) ", + "dictFileExist": "{{name}} 已经存在了", + "dictFileAddSuccess": "已添加 {{name}}", + "dictFileRemoveSuccess": "已删除 {{name}}", + "dictFileSetDefaultSuccess": "设置成功", + "dictionaries": "词典", + "import": "导入", + "default": "默认", + "setDefault": "设为默认", + "dictSettings": "词典设置", + "dictSettingsShort": "词典设置", + "importDict": "导入词典", + "dictRemoved": "成功删除词典", + "interrupted": "已中断", + "paused": "已暂停", + "completedAndChecking": "已完成 (正在检查)", + "decompressing": "正在解压", + "resume": "恢复", + "dictEmpty": "没有导入的词典", + "removing": "正在删除", + "removeDictTitle": "你确定要删除词典吗?", + "removeDictDescription": "此操作将会从本地删除词典文件,下次安装需要重新下载", + "downloadingDict": "正在下载" } diff --git a/enjoy/src/main.ts b/enjoy/src/main.ts index 21cada3a..5e86c98f 100644 --- a/enjoy/src/main.ts +++ b/enjoy/src/main.ts @@ -35,6 +35,11 @@ if (!process.env.CI) { }); } +if (!app.isPackaged) { + app.disableHardwareAcceleration(); + app.commandLine.appendSwitch("disable-software-rasterizer"); +} + // Add context menu contextMenu({ showSearchWithGoogle: false, @@ -64,7 +69,10 @@ contextMenu({ !parameters.selectionText.trim().includes(" "), click: () => { const { x, y, selectionText } = parameters; - browserWindow.webContents.send("on-lookup", selectionText, { x, y }); + browserWindow.webContents.send("on-lookup", selectionText, "", { + x, + y, + }); }, }, { diff --git a/enjoy/src/main/camdict.ts b/enjoy/src/main/camdict.ts index 9832bed2..ca64f303 100644 --- a/enjoy/src/main/camdict.ts +++ b/enjoy/src/main/camdict.ts @@ -66,6 +66,7 @@ class Camdict { const item = await this.db?.findOne({ where: { word: word.trim().toLowerCase() }, }); + return item?.toJSON(); } diff --git a/enjoy/src/main/decompresser.ts b/enjoy/src/main/decompresser.ts new file mode 100644 index 00000000..84878b43 --- /dev/null +++ b/enjoy/src/main/decompresser.ts @@ -0,0 +1,83 @@ +import { ipcMain } from "electron"; +import fs from "fs-extra"; +import { readdirSync } from "fs"; +import unzipper from "unzipper"; +import mainWin from "@main/window"; + +class Decompresser { + public tasks: DecompressTask[]; + + constructor() { + this.tasks = []; + } + + async depress(task: DecompressTask) { + if (this.tasks.find(({ id }) => task.id === id)) return; + this.add(task); + + const tempPath = task.destPath + ".depressing"; + + if (fs.existsSync(tempPath)) await fs.remove(tempPath); + const directory = await unzipper.Open.file(task.filePath); + this.onProgress(task, directory.numberOfRecords); + + await directory.extract({ path: task.destPath + ".depressing" }); + await fs.rename(task.destPath + ".depressing", task.destPath); + await fs.remove(task.filePath); + + this.remove(task); + } + + async onProgress(task: DecompressTask, total: number) { + let progress = "0"; + + if (fs.existsSync(task.destPath + ".depressing")) { + const dir = readdirSync(task.destPath + ".depressing", { + recursive: true, + }); + + progress = ((dir.length / total) * 100).toFixed(0); + } + + const currentTask = this.tasks.find(({ id }) => id === task.id); + + this.update({ ...currentTask, progress }); + + if (currentTask) { + setTimeout(() => { + this.onProgress(task, total); + }, 5000); + } + } + + add(task: DecompressTask) { + this.tasks = [...this.tasks, task]; + this.notify(); + } + + remove(task: DecompressTask) { + this.tasks = this.tasks.filter(({ id }) => id !== task.id); + this.notify(); + } + + update(task: DecompressTask) { + const index = this.tasks.findIndex(({ id }) => id === task.id); + + if (index > -1) { + this.tasks.splice(index, 1, task); + this.notify(); + } + } + + notify() { + mainWin.win.webContents.send("decompress-tasks-update", this.tasks); + } + + registerIpcHandlers() { + ipcMain.handle("decompress-tasks", () => { + return this.tasks; + }); + } +} + +export default new Decompresser(); diff --git a/enjoy/src/main/dict.ts b/enjoy/src/main/dict.ts new file mode 100644 index 00000000..24578a54 --- /dev/null +++ b/enjoy/src/main/dict.ts @@ -0,0 +1,188 @@ +import path from "path"; +import fs from "fs-extra"; +import { ipcMain } from "electron"; +import { LRUCache } from "lru-cache"; +import log from "@main/logger"; +import { DICTS } from "@/constants/dicts"; +import sqlite3, { Database } from "sqlite3"; +import settings from "./settings"; +import downloader from "./downloader"; +import decompresser from "./decompresser"; +import { hashFile } from "@/main/utils"; + +const logger = log.scope("dict"); +const sqlite = sqlite3.verbose(); + +export class DictHandler { + private cache = new LRUCache({ max: 20 }); + private db: Database; + private currentDict: string; + + get dictsPath() { + const _path = path.join(settings.libraryPath(), "dictionaries"); + fs.ensureDirSync(_path); + + return _path; + } + + async isDictFileValid(dict: Dict) { + const filePath = path.join(this.dictsPath, dict.fileName); + + if (!fs.existsSync(filePath)) return false; + + const hash = await hashFile(filePath, { algo: "md5" }); + + return hash === dict.hash; + } + + async download(dict: Dict) { + const filePath = path.join(this.dictsPath, dict.fileName); + const dictPath = path.join(this.dictsPath, dict.name); + + if (fs.existsSync(dictPath)) { + throw new Error("Dictionary already exists"); + } + + const isDictFileValid = await this.isDictFileValid(dict); + + if (isDictFileValid) { + this.decompress(dict); + } else { + if (fs.existsSync(filePath)) { + await fs.remove(filePath); + } + + downloader.download(dict.downloadUrl, { + savePath: this.dictsPath, + }); + } + } + + async decompress(dict: Dict) { + const filePath = path.join(this.dictsPath, dict.fileName); + const dictPath = path.join(this.dictsPath, dict.name); + const isDictFileValid = await this.isDictFileValid(dict); + + if (isDictFileValid) { + await decompresser.depress({ + filePath, + hash: dict.hash, + destPath: dictPath, + id: `dict-${dict.fileName}`, + }); + } + + downloader.remove(dict.fileName); + } + + async remove(dict: Dict) { + await fs.remove(path.join(this.dictsPath, dict.name)); + } + + async lookup(word: string, dict: Dict) { + if (this.currentDict !== dict.name) { + this.db = new sqlite.Database( + path.join(this.dictsPath, dict.name, `${dict.name}.sqlite`) + ); + this.currentDict = dict.name; + } + + const result = await this.query(word); + + return result ? `${dict.addition}${result}` : null; + } + + query(word: string) { + return new Promise((resolve, reject) => { + this.db.get( + `SELECT definition FROM definitions WHERE id=(SELECT definition_id FROM words WHERE word="${word}")`, + (err, row: any) => { + if (err) reject(err); + resolve(row?.definition ?? ""); + } + ); + }); + } + + async getDicts() { + const dicts = DICTS.map((dict: Dict) => { + let state: DictState = "uninstall"; + let downloadState; + let decompressProgress; + + const files = fs.readdirSync(this.dictsPath); + const isInstalled = files.find((file) => file === dict.name); + + const decompressTask = decompresser.tasks.find( + (task) => task.id === `dict-${dict.fileName}` + ); + + const downloadTask = downloader.tasks.find( + (task) => task.getFilename() === dict.fileName + ); + + if (decompressTask) { + state = "decompressing"; + decompressProgress = decompressTask.progress; + } else if (isInstalled) { + state = "installed"; + } else if (downloadTask) { + state = "downloading"; + downloadState = { + name: downloadTask.getFilename(), + state: downloadTask.getState(), + isPaused: downloadTask.isPaused(), + canResume: downloadTask.canResume(), + total: downloadTask.getTotalBytes(), + received: downloadTask.getReceivedBytes(), + }; + } + + return { ...dict, state, downloadState, decompressProgress }; + }); + + return dicts; + } + + getResource(key: string, dict: Dict) { + const filePath = path.join(this.dictsPath, dict.name, key); + const cachedValue = this.cache.get(filePath); + if (cachedValue) return cachedValue; + + try { + const data = fs.readFileSync(filePath, { encoding: "base64" }); + this.cache.set(filePath, data); + + return data; + } catch (err) { + logger.error(`Failed to read file ${filePath}`, err); + return ""; + } + } + + registerIpcHandlers() { + ipcMain.handle("dict-download", async (_event, dict: Dict) => + this.download(dict) + ); + + ipcMain.handle("dict-decompress", async (_event, dict: Dict) => + this.decompress(dict) + ); + + ipcMain.handle("dict-remove", async (_event, dict: Dict) => + this.remove(dict) + ); + + ipcMain.handle("dict-list", async (_event) => this.getDicts()); + + ipcMain.handle("dict-read-file", async (_event, path: string, dict: Dict) => + this.getResource(path, dict) + ); + + ipcMain.handle("dict-lookup", async (_event, word: string, dict: Dict) => + this.lookup(word, dict) + ); + } +} + +export default new DictHandler(); diff --git a/enjoy/src/main/downloader.ts b/enjoy/src/main/downloader.ts index f9fbea30..6720f359 100644 --- a/enjoy/src/main/downloader.ts +++ b/enjoy/src/main/downloader.ts @@ -20,8 +20,10 @@ class Downloader { } ): Promise { const { webContents = mainWin.win.webContents, savePath } = options || {}; + return new Promise((resolve, _reject) => { webContents.downloadURL(url); + webContents.session.on("will-download", (_event, item, _webContents) => { if (savePath) { try { @@ -115,8 +117,27 @@ class Downloader { }); } + pause(filename: string) { + this.tasks + .filter( + (t) => t.getFilename() === filename && t.getState() === "progressing" + ) + .forEach((t) => { + t.pause(); + }); + } + + resume(filename: string) { + this.tasks + .filter( + (t) => t.getFilename() === filename && t.getState() === "progressing" + ) + .forEach((t) => { + t.resume(); + }); + } + cancel(filename: string) { - logger.debug("dashboard", this.dashboard()); this.tasks .filter( (t) => t.getFilename() === filename && t.getState() === "progressing" @@ -126,6 +147,11 @@ class Downloader { }); } + remove(filename: string) { + this.cancel(filename); + this.tasks = this.tasks.filter((t) => t.getFilename() !== filename); + } + cancelAll() { for (const task of this.tasks) { task.cancel(); @@ -154,9 +180,17 @@ class Downloader { }); }); ipcMain.handle("download-cancel", (_event, filename) => { - logger.debug("download-cancel", filename); this.cancel(filename); }); + ipcMain.handle("download-pause", (_event, filename) => { + this.pause(filename); + }); + ipcMain.handle("download-resume", (_event, filename) => { + this.resume(filename); + }); + ipcMain.handle("download-remove", (_event, filename) => { + this.remove(filename); + }); ipcMain.handle("download-cancel-all", () => { this.cancelAll(); }); diff --git a/enjoy/src/main/settings.ts b/enjoy/src/main/settings.ts index 05df2629..808a68b9 100644 --- a/enjoy/src/main/settings.ts +++ b/enjoy/src/main/settings.ts @@ -182,6 +182,14 @@ export default { return settings.setSync("defaultHotkeys", records); }); + ipcMain.handle("settings-get-dict", (_event) => { + return settings.getSync("dicts"); + }); + + ipcMain.handle("settings-set-dicts", (_event, dict) => { + return settings.setSync("dicts", dict); + }); + ipcMain.handle("settings-get-api-url", (_event) => { return settings.getSync("apiUrl"); }); @@ -189,6 +197,14 @@ export default { ipcMain.handle("settings-set-api-url", (_event, url) => { return settings.setSync("apiUrl", url); }); + + ipcMain.handle("settings-get-vocabulary-config", (_event) => { + return settings.getSync("vocabularyConfig"); + }); + + ipcMain.handle("settings-set-vocabulary-config", (_event, records) => { + return settings.setSync("vocabularyConfig", records); + }); }, cachePath, libraryPath, diff --git a/enjoy/src/main/window.ts b/enjoy/src/main/window.ts index 2b491dd1..8691966a 100644 --- a/enjoy/src/main/window.ts +++ b/enjoy/src/main/window.ts @@ -23,6 +23,8 @@ import { Waveform } from "./waveform"; import url from "url"; import echogarden from "./echogarden"; import camdict from "./camdict"; +import dict from "./dict"; +import decompresser from "./decompresser"; const __filename = url.fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -50,6 +52,7 @@ main.init = () => { db.registerIpcHandlers(); camdict.registerIpcHandlers(); + dict.registerIpcHandlers(); // Prepare Settings settings.registerIpcHandlers(); @@ -66,6 +69,8 @@ main.init = () => { // Downloader downloader.registerIpcHandlers(); + decompresser.registerIpcHandlers(); + // ffmpeg ffmpeg.registerIpcHandlers(); diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index 096c621d..017241b8 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -141,10 +141,16 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { onNotification: ( callback: (event: IpcRendererEvent, notification: NotificationType) => void ) => ipcRenderer.on("on-notification", callback), + lookup: ( + selection: string, + context: string, + position: { x: number; y: number } + ) => ipcRenderer.emit("on-lookup", null, selection, context, position), onLookup: ( callback: ( event: IpcRendererEvent, selection: string, + context: string, position: { x: number; y: number } ) => void ) => ipcRenderer.on("on-lookup", callback), @@ -158,6 +164,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { position: { x: number; y: number } ) => void ) => ipcRenderer.on("on-translate", callback), + offTranslate: () => { + ipcRenderer.removeAllListeners("on-translate"); + }, shell: { openExternal: (url: string) => ipcRenderer.invoke("shell-open-external", url), @@ -225,12 +234,24 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { setDefaultHotkeys: (records: Record) => { return ipcRenderer.invoke("settings-set-default-hotkeys", records); }, + getDictSettings: () => { + return ipcRenderer.invoke("settings-get-dict"); + }, + setDictSettings: (dict: DictSettingType) => { + return ipcRenderer.invoke("settings-set-dicts", dict); + }, getApiUrl: () => { return ipcRenderer.invoke("settings-get-api-url"); }, setApiUrl: (url: string) => { return ipcRenderer.invoke("settings-set-api-url", url); }, + getVocabularyConfig: () => { + return ipcRenderer.invoke("settings-get-vocabulary-config"); + }, + setVocabularyConfig: (records: Record) => { + return ipcRenderer.invoke("settings-set-vocabulary-config", records); + }, }, path: { join: (...paths: string[]) => { @@ -258,6 +279,16 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { return ipcRenderer.invoke("camdict-lookup", word); }, }, + dict: { + getDicts: () => ipcRenderer.invoke("dict-list"), + download: (dict: Dict) => ipcRenderer.invoke("dict-download", dict), + decompress: (dict: Dict) => ipcRenderer.invoke("dict-decompress", dict), + remove: (dict: Dict) => ipcRenderer.invoke("dict-remove", dict), + getResource: (key: string, dict: Dict) => + ipcRenderer.invoke("dict-read-file", key, dict), + lookup: (word: string, dict: Dict) => + ipcRenderer.invoke("dict-lookup", word, dict), + }, audios: { findAll: (params: { offset: number | undefined; @@ -526,28 +557,33 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { return ipcRenderer.invoke("ffmpeg-transcode", input, output, options); }, }, + decompress: { + onUpdate: ( + callback: (event: IpcRendererEvent, tasks: DecompressTask[]) => void + ) => ipcRenderer.on("decompress-tasks-update", callback), + dashboard: () => ipcRenderer.invoke("decompress-tasks"), + removeAllListeners: () => + ipcRenderer.removeAllListeners("decompress-tasks-update"), + }, download: { onState: ( callback: (event: IpcRendererEvent, state: DownloadStateType) => void ) => ipcRenderer.on("download-on-state", callback), - start: (url: string, savePath?: string) => { - return ipcRenderer.invoke("download-start", url, savePath); - }, - printAsPdf: (content: string, savePath: string) => { - return ipcRenderer.invoke("print-as-pdf", content, savePath); - }, - cancel: (filename: string) => { - ipcRenderer.invoke("download-cancel", filename); - }, - cancelAll: () => { - ipcRenderer.invoke("download-cancel-all"); - }, - dashboard: () => { - return ipcRenderer.invoke("download-dashboard"); - }, - removeAllListeners: () => { - ipcRenderer.removeAllListeners("download-on-error"); - }, + start: (url: string, savePath?: string) => + ipcRenderer.invoke("download-start", url, savePath), + printAsPdf: (content: string, savePath: string) => + ipcRenderer.invoke("print-as-pdf", content, savePath), + cancel: (filename: string) => + ipcRenderer.invoke("download-cancel", filename), + pause: (filename: string) => ipcRenderer.invoke("download-pause", filename), + remove: (filename: string) => + ipcRenderer.invoke("download-remove", filename), + resume: (filename: string) => + ipcRenderer.invoke("download-resume", filename), + cancelAll: () => ipcRenderer.invoke("download-cancel-all"), + dashboard: () => ipcRenderer.invoke("download-dashboard"), + removeAllListeners: () => + ipcRenderer.removeAllListeners("download-on-error"), }, cacheObjects: { get: (key: string) => { diff --git a/enjoy/src/renderer/app.tsx b/enjoy/src/renderer/app.tsx index 5b0788a0..37ebdc60 100644 --- a/enjoy/src/renderer/app.tsx +++ b/enjoy/src/renderer/app.tsx @@ -4,6 +4,7 @@ import { AppSettingsProvider, DbProvider, HotKeysSettingsProvider, + DictProvider, } from "@renderer/context"; import router from "./router"; import { RouterProvider } from "react-router-dom"; @@ -37,13 +38,15 @@ function App() { - - - - - - - + + + + + + + + + diff --git a/enjoy/src/renderer/components/medias/media-caption.tsx b/enjoy/src/renderer/components/medias/media-caption.tsx index 2e57b722..644796b9 100644 --- a/enjoy/src/renderer/components/medias/media-caption.tsx +++ b/enjoy/src/renderer/components/medias/media-caption.tsx @@ -5,7 +5,7 @@ import { } from "@renderer/context"; import cloneDeep from "lodash/cloneDeep"; import { Button, toast } from "@renderer/components/ui"; -import { ConversationShortcuts } from "@renderer/components"; +import { ConversationShortcuts, Vocabulary } from "@renderer/components"; import { t } from "i18next"; import { BotIcon, @@ -544,7 +544,7 @@ export const Caption = (props: { }`} onClick={() => onClick && onClick(index)} > - {word} + {displayIpa && ( diff --git a/enjoy/src/renderer/components/preferences/dict-settings/dict-import-button.tsx b/enjoy/src/renderer/components/preferences/dict-settings/dict-import-button.tsx new file mode 100644 index 00000000..1a10591b --- /dev/null +++ b/enjoy/src/renderer/components/preferences/dict-settings/dict-import-button.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + ScrollArea, +} from "@/renderer/components/ui"; +import { UninstallDictList } from "."; +import { t } from "i18next"; + +export const DictImportButton = () => { + const [open, setOpen] = useState(false); + + const handleOpen = (value: boolean) => { + setOpen(value); + }; + + const handleDownload = () => { + setOpen(false); + }; + + return ( + + + + + + + {t("importDict")} + + + + + + + + ); +}; diff --git a/enjoy/src/renderer/components/preferences/dict-settings/dict-settings.tsx b/enjoy/src/renderer/components/preferences/dict-settings/dict-settings.tsx new file mode 100644 index 00000000..519b84c8 --- /dev/null +++ b/enjoy/src/renderer/components/preferences/dict-settings/dict-settings.tsx @@ -0,0 +1,21 @@ +import { t } from "i18next"; +import { DictImportButton } from "./dict-import-button"; +import { DownloadingDictList, InstalledDictList } from "."; + +export const DictSettings = () => { + return ( + <> +
+
+
{t("dictionaries")}
+ +
+ +
+ + +
+
+ + ); +}; diff --git a/enjoy/src/renderer/components/preferences/dict-settings/downloading-dict-list.tsx b/enjoy/src/renderer/components/preferences/dict-settings/downloading-dict-list.tsx new file mode 100644 index 00000000..6124067b --- /dev/null +++ b/enjoy/src/renderer/components/preferences/dict-settings/downloading-dict-list.tsx @@ -0,0 +1,184 @@ +import { + DictProviderContext, + AppSettingsProviderContext, +} from "@/renderer/context"; +import { useContext, useEffect, useState } from "react"; +import { Button, toast } from "@renderer/components/ui"; +import { t } from "i18next"; +import { LoaderSpin } from "@renderer/components"; + +export const DownloadingDictList = function () { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { downloadingDicts, reload } = useContext(DictProviderContext); + + useEffect(() => { + listenToDownloadState(); + listenDecompressState(); + + return () => { + EnjoyApp.download.removeAllListeners(); + EnjoyApp.decompress.removeAllListeners(); + }; + }, []); + + const listenToDownloadState = () => { + EnjoyApp.download.onState((_event, state) => { + reload(); + }); + }; + + const listenDecompressState = () => { + EnjoyApp.decompress.onUpdate((_event, tasks) => { + reload(); + }); + }; + + return ( + <> + {downloadingDicts.map((item) => ( + + ))} + + ); +}; + +const DownloadingDictItem = function ({ dict }: { dict: Dict }) { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { reload } = useContext(DictProviderContext); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (dict.downloadState?.state === "completed") { + EnjoyApp.dict.decompress(dict); + } + }, [dict]); + + async function handlePause() { + setLoading(true); + + try { + await EnjoyApp.download.pause(dict.downloadState.name); + reload(); + } catch (err) { + toast.error(err.message); + } + + setLoading(false); + } + + async function handleResume() { + setLoading(true); + + try { + await EnjoyApp.download.resume(dict.downloadState.name); + reload(); + } catch (err) { + toast.error(err.message); + } + + setLoading(false); + } + + async function handleRemove() { + setLoading(true); + + try { + await EnjoyApp.download.remove(dict.downloadState.name); + toast.success(t("dictRemoved")); + reload(); + } catch (err) { + toast.error(err.message); + } + + setLoading(false); + } + + function displaySize(bytes: number) { + return Number((bytes / 1024 / 1024).toFixed(0)).toLocaleString() + "MB"; + } + + function renderDownloadState() { + const text = + dict.downloadState.state === "cancelled" + ? t("cancelled") + : dict.downloadState.state === "completed" + ? t("completedAndChecking") + : dict.downloadState.state === "interrupted" + ? t("interrupted") + : dict.downloadState.isPaused + ? t("paused") + : t("downloadingDict"); + + return ( +
+ {text} + {displaySize(dict.downloadState.received)} + / + {displaySize(dict.downloadState.total)} +
+ ); + } + + function renderDecompressState() { + return ( +
+ {t("decompressing")} + {dict.decompressProgress ?? "0"}% +
+ ); + } + + function renderActions() { + if (loading) return ; + + if ( + dict.downloadState?.state === "progressing" && + !dict.downloadState?.isPaused + ) { + return ( + + ); + } + + if ( + dict.downloadState?.state === "cancelled" || + dict.downloadState?.state === "interrupted" || + (dict.downloadState?.state === "progressing" && + dict.downloadState?.isPaused) + ) { + return ( + <> + {dict.downloadState.canResume && ( + + )} + + + + ); + } + } + + return ( +
+
+
{dict.title}
+
+ {dict.state === "decompressing" && renderDecompressState()} + {dict.downloadState && renderDownloadState()} +
+
+ {renderActions()} +
+ ); +}; diff --git a/enjoy/src/renderer/components/preferences/dict-settings/index.ts b/enjoy/src/renderer/components/preferences/dict-settings/index.ts new file mode 100644 index 00000000..498272e7 --- /dev/null +++ b/enjoy/src/renderer/components/preferences/dict-settings/index.ts @@ -0,0 +1,5 @@ +export * from "./dict-settings"; +export * from "./dict-import-button"; +export * from "./downloading-dict-list"; +export * from "./installed-dict-list"; +export * from "./uninstall-dict-list"; diff --git a/enjoy/src/renderer/components/preferences/dict-settings/installed-dict-list.tsx b/enjoy/src/renderer/components/preferences/dict-settings/installed-dict-list.tsx new file mode 100644 index 00000000..dd6d820e --- /dev/null +++ b/enjoy/src/renderer/components/preferences/dict-settings/installed-dict-list.tsx @@ -0,0 +1,148 @@ +import { + DictProviderContext, + AppSettingsProviderContext, +} from "@/renderer/context"; +import { useContext, useEffect, useState } from "react"; +import { + Button, + toast, + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@renderer/components/ui"; +import { t } from "i18next"; + +export const InstalledDictList = function () { + const { installedDicts, downloadingDicts, reload } = + useContext(DictProviderContext); + + useEffect(() => { + reload(); + }, []); + + if (installedDicts.length === 0 && downloadingDicts.length === 0) { + return ( +
{t("dictEmpty")}
+ ); + } + + return ( + <> + {installedDicts.map((item) => ( + + ))} + + ); +}; + +const InstalledDictItem = function ({ dict }: { dict: Dict }) { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { settings, setDefault, reload, remove, removed } = + useContext(DictProviderContext); + const [removing, setRemoving] = useState(false); + + useEffect(() => { + if (settings.removing?.find((v) => v === dict.name)) { + handleRemove(); + } + }, []); + + async function handleSetDefault() { + try { + await setDefault(dict); + toast.success(t("dictFileSetDefaultSuccess")); + } catch (err) { + toast.error(err.message); + } + } + + async function handleRemove() { + setRemoving(true); + + try { + remove(dict); + await EnjoyApp.dict.remove(dict); + removed(dict); + toast.success(t("dictRemoved")); + } catch (err) { + toast.error(err.message); + } + + setRemoving(false); + reload(); + } + + function renderActions() { + if (removing) { + return ( + {t("removing")} + ); + } + + if (dict.state === "installed") { + return ( +
+ + + + + + + {t("removeDictTitle")} + + {t("removeDictDescription")} + + + + {t("cancel")} + + + + + + + + +
+ ); + } + } + + return ( +
+
+ {dict.title} + {settings.default === dict.name && ( + + {t("default")} + + )} +
+ + {renderActions()} +
+ ); +}; diff --git a/enjoy/src/renderer/components/preferences/dict-settings/uninstall-dict-list.tsx b/enjoy/src/renderer/components/preferences/dict-settings/uninstall-dict-list.tsx new file mode 100644 index 00000000..3f041da1 --- /dev/null +++ b/enjoy/src/renderer/components/preferences/dict-settings/uninstall-dict-list.tsx @@ -0,0 +1,76 @@ +import { + DictProviderContext, + AppSettingsProviderContext, +} from "@/renderer/context"; +import { useContext, useState } from "react"; +import { Button, toast } from "@renderer/components/ui"; +import { LoaderIcon } from "lucide-react"; +import { t } from "i18next"; + +export const UninstallDictList = function ({ + onDownload, +}: { + onDownload: () => void; +}) { + const { uninstallDicts } = useContext(DictProviderContext); + + return ( + <> + {uninstallDicts.map((item) => ( + + ))} + + ); +}; + +const UninstallDictItem = function ({ + dict, + onDownload, +}: { + dict: Dict; + onDownload: () => void; +}) { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { reload, removed } = useContext(DictProviderContext); + const [loading, setLoading] = useState(false); + + async function handleDownload() { + setLoading(true); + + try { + removed(dict); + await EnjoyApp.dict.download(dict); + reload(); + onDownload(); + } catch (err) { + toast.error(err); + } + + setLoading(false); + } + + return ( +
+
+
{dict.title}
+
+ {dict.lang} + {dict.size} +
+
+ +
+ ); +}; diff --git a/enjoy/src/renderer/components/preferences/index.ts b/enjoy/src/renderer/components/preferences/index.ts index 53a9d1fa..22d45d3a 100644 --- a/enjoy/src/renderer/components/preferences/index.ts +++ b/enjoy/src/renderer/components/preferences/index.ts @@ -30,4 +30,7 @@ export * from "./proxy-settings"; export * from "./whisper-model-options"; export * from "./network-state"; -export * from "./recorder-settings"; \ No newline at end of file +export * from "./recorder-settings"; + +export * from "./vocabulary-settings"; +export * from "./dict-settings"; diff --git a/enjoy/src/renderer/components/preferences/preferences.tsx b/enjoy/src/renderer/components/preferences/preferences.tsx index 95179f23..3fd7c604 100644 --- a/enjoy/src/renderer/components/preferences/preferences.tsx +++ b/enjoy/src/renderer/components/preferences/preferences.tsx @@ -18,6 +18,8 @@ import { LearningLanguageSettings, NetworkState, RecorderSettings, + VocabularySettings, + DictSettings, } from "@renderer/components"; import { useState } from "react"; import { Tooltip } from "react-tooltip"; @@ -46,6 +48,21 @@ export const Preferences = () => { ), }, + { + value: "dict", + label: t("dictSettingsShort"), + component: () => ( +
+
+ {t("dictSettings")} +
+ + + + +
+ ), + }, { value: "advanced", label: t("advancedSettingsShort"), diff --git a/enjoy/src/renderer/components/preferences/vocabulary-settings.tsx b/enjoy/src/renderer/components/preferences/vocabulary-settings.tsx new file mode 100644 index 00000000..03d92fb1 --- /dev/null +++ b/enjoy/src/renderer/components/preferences/vocabulary-settings.tsx @@ -0,0 +1,30 @@ +import { t } from "i18next"; +import { Switch } from "@renderer/components/ui"; +import { useContext } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; + +export const VocabularySettings = () => { + const { vocabularyConfig, setVocabularyConfig } = useContext( + AppSettingsProviderContext + ); + + return ( +
+
+
{t("lookupOnMouseOver")}
+
+ +
+ { + setVocabularyConfig({ + ...vocabularyConfig, + lookupOnMouseOver: !vocabularyConfig.lookupOnMouseOver, + }); + }} + /> +
+
+ ); +}; diff --git a/enjoy/src/renderer/components/ui/select.tsx b/enjoy/src/renderer/components/ui/select.tsx index 704b3061..d6c195a2 100644 --- a/enjoy/src/renderer/components/ui/select.tsx +++ b/enjoy/src/renderer/components/ui/select.tsx @@ -24,7 +24,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground hover:outline-none hover:ring-1 hover:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} {...props} diff --git a/enjoy/src/renderer/components/widgets/index.ts b/enjoy/src/renderer/components/widgets/index.ts index 2c08c43e..174ce4b9 100644 --- a/enjoy/src/renderer/components/widgets/index.ts +++ b/enjoy/src/renderer/components/widgets/index.ts @@ -1,2 +1,4 @@ -export * from "./lookup-widget"; +export * from "./lookup"; export * from "./translate-widget"; +export * from "./vocabulary"; +export * from "./lookup/dict-lookup-result"; diff --git a/enjoy/src/renderer/components/widgets/lookup-widget.tsx b/enjoy/src/renderer/components/widgets/lookup-widget.tsx deleted file mode 100644 index c347d0be..00000000 --- a/enjoy/src/renderer/components/widgets/lookup-widget.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { useEffect, useContext, useState } from "react"; -import { AppSettingsProviderContext } from "@renderer/context"; -import { - Button, - Popover, - PopoverAnchor, - PopoverContent, - ScrollArea, - Separator, - toast, -} from "@renderer/components/ui"; -import { useAiCommand, useCamdict } from "@renderer/hooks"; -import { LoaderIcon, Volume2Icon } from "lucide-react"; -import { t } from "i18next"; -import { md5 } from "js-md5"; - -export const LookupWidget = () => { - const { EnjoyApp, learningLanguage } = useContext(AppSettingsProviderContext); - const [open, setOpen] = useState(false); - const [selected, setSelected] = useState<{ - word: string; - context?: string; - sourceType?: string; - sourceId?: string; - position: { - x: number; - y: number; - }; - }>(); - - const handleSelectionChanged = (position: { x: number; y: number }) => { - const selection = document.getSelection(); - if (!selection?.anchorNode?.parentElement) return; - - const word = selection - .toString() - .trim() - .replace(/[.,/#!$%^&*;:{}=\-_`~()]+$/, ""); - if (!word) return; - // can only lookup single word - if (word.indexOf(" ") > -1) return; - - const context = selection.anchorNode.parentElement - .closest(".sentence, h2, p, div") - ?.textContent?.trim(); - - const sourceType = selection.anchorNode.parentElement - .closest("[data-source-type]") - ?.getAttribute("data-source-type"); - const sourceId = selection.anchorNode.parentElement - .closest("[data-source-id]") - ?.getAttribute("data-source-id"); - - setSelected({ - word, - context, - position: { - x: position.x, - y: position.y + window.scrollY + 10, - }, - sourceType, - sourceId, - }); - setOpen(true); - }; - - useEffect(() => { - EnjoyApp.onLookup((_event, _selection, position) => { - handleSelectionChanged(position); - }); - - return () => EnjoyApp.offLookup(); - }, []); - - return ( - - - - {selected?.word && ( - -
- {selected?.word} -
-
- {learningLanguage.startsWith("en") && ( - <> - - - - )} - -
-
- )} -
-
- ); -}; - -export const AiLookupResult = (props: { - word: string; - context?: string; - sourceType?: string; - sourceId?: string; -}) => { - const { word, context = "", sourceType, sourceId } = props; - const { webApi, EnjoyApp } = useContext(AppSettingsProviderContext); - - const [lookingUp, setLookingUp] = useState(false); - const [result, setResult] = useState(); - const { lookupWord } = useAiCommand(); - - const handleLookup = async (options?: { force: boolean }) => { - if (lookingUp) return; - if (!word) return; - const { force = false } = options || {}; - - setLookingUp(true); - lookupWord({ - word, - context, - sourceId, - sourceType, - cacheKey: `lookup-${md5(`${word}-${context}`)}`, - force, - }) - .then((lookup) => { - if (lookup?.meaning) { - setResult(lookup); - } - }) - .catch((error) => { - toast.error(error.message); - }) - .finally(() => { - setLookingUp(false); - }); - }; - - const fetchCachedLookup = async () => { - const remoteLookup = await webApi.lookup({ - word, - context, - sourceId, - sourceType, - }); - if (remoteLookup?.meaning) { - setResult(remoteLookup); - return; - } - - const cached = await EnjoyApp.cacheObjects.get( - `lookup-${md5(`${word}-${context}`)}` - ); - if (cached?.meaning) { - setResult(cached); - return; - } - - setResult(undefined); - }; - - /* - * Fetch cached lookup result. - */ - useEffect(() => { - if (!word) return; - - fetchCachedLookup(); - }, [word, context]); - - if (!word) return null; - - return ( - <> -
- {t("aiDictionary")} -
- {result ? ( - <> -
-
{word}
-
- {result.meaning?.pos && ( - - {result.meaning.pos} - - )} - {result.meaning?.pronunciation && ( - - /{result.meaning.pronunciation.replaceAll("/", "")}/ - - )} - {result.meaning?.lemma && - result.meaning.lemma !== result.meaning.word && ( - ({result.meaning.lemma}) - )} -
-
{result.meaning.translation}
-
{result.meaning.definition}
-
-
- -
- - ) : ( - - )} - - ); -}; - -export const CamdictLookupResult = (props: { word: string }) => { - const { word } = props; - const { result } = useCamdict(word); - - if (!word) return null; - - return ( - <> -
- {t("cambridgeDictionary")} -
- {result ? ( -
-
{word}
- {result.posItems.map((posItem, index) => ( -
-
-
- {posItem.type} -
- - {posItem.pronunciations.map((pron, i) => ( -
- - [{pron.region}] - - - /{pron.pronunciation}/ - - {pron.audio && pron.audio.match(/\.mp3/i) && ( -
- -
- )} -
- ))} -
-
    - {posItem.definitions.map((def, i) => ( -
  • - {def.definition} -
  • - ))} -
-
- ))} -
- ) : ( -
- - {t("noResultsFound")} - -
- )} - - ); -}; diff --git a/enjoy/src/renderer/components/widgets/lookup/ai-lookup-result.tsx b/enjoy/src/renderer/components/widgets/lookup/ai-lookup-result.tsx new file mode 100644 index 00000000..9a799dd7 --- /dev/null +++ b/enjoy/src/renderer/components/widgets/lookup/ai-lookup-result.tsx @@ -0,0 +1,145 @@ +import { useEffect, useContext, useState } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { Button, toast } from "@renderer/components/ui"; +import { useAiCommand } from "@renderer/hooks"; +import { LoaderIcon } from "lucide-react"; +import { t } from "i18next"; +import { md5 } from "js-md5"; + +export const AiLookupResult = (props: { + word: string; + context?: string; + sourceType?: string; + sourceId?: string; +}) => { + const { word, context = "", sourceType, sourceId } = props; + const { webApi, EnjoyApp } = useContext(AppSettingsProviderContext); + + const [lookingUp, setLookingUp] = useState(false); + const [result, setResult] = useState(); + const { lookupWord } = useAiCommand(); + + const handleLookup = async (options?: { force: boolean }) => { + if (lookingUp) return; + if (!word) return; + const { force = false } = options || {}; + + setLookingUp(true); + lookupWord({ + word, + context, + sourceId, + sourceType, + cacheKey: `lookup-${md5(`${word}-${context}`)}`, + force, + }) + .then((lookup) => { + if (lookup?.meaning) { + setResult(lookup); + } + }) + .catch((error) => { + toast.error(error.message); + }) + .finally(() => { + setLookingUp(false); + }); + }; + + const fetchCachedLookup = async () => { + const remoteLookup = await webApi.lookup({ + word, + context, + sourceId, + sourceType, + }); + if (remoteLookup?.meaning) { + setResult(remoteLookup); + return; + } + + const cached = await EnjoyApp.cacheObjects.get( + `lookup-${md5(`${word}-${context}`)}` + ); + if (cached?.meaning) { + setResult(cached); + return; + } + + setResult(undefined); + }; + + /* + * Fetch cached lookup result. + */ + useEffect(() => { + if (!word || !context) return; + + fetchCachedLookup(); + }, [word, context]); + + if (!word) return null; + + return ( + <> + {result ? ( + <> +
+
{word}
+
+ {result.meaning?.pos && ( + + {result.meaning.pos} + + )} + {result.meaning?.pronunciation && ( + + /{result.meaning.pronunciation.replaceAll("/", "")}/ + + )} + {result.meaning?.lemma && + result.meaning.lemma !== result.meaning.word && ( + ({result.meaning.lemma}) + )} +
+
{result.meaning.translation}
+
{result.meaning.definition}
+
+
+ +
+ + ) : ( + + )} + + ); +}; diff --git a/enjoy/src/renderer/components/widgets/lookup/camdict-lookup-result.tsx b/enjoy/src/renderer/components/widgets/lookup/camdict-lookup-result.tsx new file mode 100644 index 00000000..36378b52 --- /dev/null +++ b/enjoy/src/renderer/components/widgets/lookup/camdict-lookup-result.tsx @@ -0,0 +1,70 @@ +import { Button } from "@renderer/components/ui"; +import { useCamdict } from "@renderer/hooks"; +import { Volume2Icon } from "lucide-react"; +import { t } from "i18next"; + +export const CamdictLookupResult = (props: { word: string }) => { + const { word } = props; + const { result } = useCamdict(word); + + if (!word) return null; + + return ( + <> + {result ? ( +
+
{word}
+ {result.posItems.map((posItem, index) => ( +
+
+
+ {posItem.type} +
+ + {posItem.pronunciations.map((pron, i) => ( +
+ + [{pron.region}] + + + /{pron.pronunciation}/ + + {pron.audio && pron.audio.match(/\.mp3/i) && ( +
+ +
+ )} +
+ ))} +
+
    + {posItem.definitions.map((def, i) => ( +
  • + {def.definition} +
  • + ))} +
+
+ ))} +
+ ) : ( +
+ - {t("noResultsFound")} - +
+ )} + + ); +}; diff --git a/enjoy/src/renderer/components/widgets/lookup/dict-lookup-result.tsx b/enjoy/src/renderer/components/widgets/lookup/dict-lookup-result.tsx new file mode 100644 index 00000000..66f9bf7b --- /dev/null +++ b/enjoy/src/renderer/components/widgets/lookup/dict-lookup-result.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState, useContext } from "react"; +import { + AppSettingsProviderContext, + DictProviderContext, + ThemeProviderContext, +} from "@/renderer/context"; +import Frame, { useFrame } from "react-frame-component"; +import { getExtension } from "@/utils"; +import { DictDefinitionNormalizer } from "@renderer/lib/dict"; +import { LoaderSpin } from "@renderer/components"; +import { t } from "i18next"; + +const MIME: Record = { + css: "text/css", + img: "image", + jpg: "image/jpeg", + png: "image/png", + spx: "audio/x-speex", + wav: "audio/wav", + mp3: "audio/mp3", + js: "text/javascript", +}; + +export function DictLookupResult({ + word, + onJump, +}: { + word: string; + onJump: (v: string) => void; +}) { + const { colorScheme } = useContext(ThemeProviderContext); + const initialContent = ``; + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { currentDict } = useContext(DictProviderContext); + const [definition, setDefinition] = useState(""); + const [looking, setLooking] = useState(false); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + if (currentDict && word) { + lookup(); + } + }, [currentDict, word]); + + async function lookup() { + revoke(); + setLooking(true); + + const _word = word.trim().indexOf(" ") > -1 ? word : word.toLowerCase(); + + EnjoyApp.dict + .lookup(_word, currentDict) + .then((result) => { + if (!result) { + setNotFound(true); + } else { + setDefinition(result); + } + }) + .catch((err) => { + setError(true); + }) + .finally(() => { + setLooking(false); + }); + } + + function revoke() { + setNotFound(false); + setLooking(false); + setError(false); + } + + if (looking) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ - {"Lookup Error"} - +
+ ); + } + + if (notFound) { + return ( +
+ - {t("noResultsFound")} - +
+ ); + } + + return ( + + + + ); +} + +export const DictLookupResultInner = ({ + text, + onJump, +}: { + text: string; + onJump: (v: string) => void; +}) => { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { currentDict } = useContext(DictProviderContext); + const { document: innerDocument } = useFrame(); + const [html, setHtml] = useState(""); + + useEffect(() => { + normalize(); + + return () => { + normalizer.revoke(); + }; + }, [text]); + + useEffect(() => { + registerAudioHandler(); + registerJumpHandler(); + }, [html]); + + const handleInjectScript = (url: string) => { + const tag = innerDocument.createElement("script"); + tag.src = url; + + innerDocument.body.appendChild(tag); + innerDocument.body.removeChild(tag); + }; + + const handleReadResource = async (key: string) => { + return EnjoyApp.dict.getResource(key, currentDict); + }; + + const normalizer = new DictDefinitionNormalizer({ + onInjectScript: handleInjectScript, + onReadResource: handleReadResource, + }); + + async function normalize() { + const html = await normalizer.normalize(text); + setHtml(html); + } + + async function handlePlayAudio(audio: Element) { + const href = audio.getAttribute("data-source"); + const data = await handleReadResource(href); + const ext: string = getExtension(href, "wav"); + const url = await normalizer.createUrl(MIME[ext] || "audio", data); + const _audio = new Audio(url); + + _audio.play(); + } + + function handleJump(el: Element) { + const word = el.getAttribute("data-word"); + onJump(word); + } + + const registerAudioHandler = () => { + const audios = innerDocument?.querySelectorAll("[data-type='audio']"); + if (!audios.length) return; + + audios.forEach((audio: Element) => { + audio.addEventListener("click", () => handlePlayAudio(audio)); + }); + + return () => { + audios.forEach((audio: Element) => { + audio.removeEventListener("click", () => handlePlayAudio(audio)); + }); + }; + }; + + const registerJumpHandler = () => { + const links = innerDocument?.querySelectorAll("[data-type='jump']"); + if (!links.length) return; + + links.forEach((el: Element) => { + el.addEventListener("click", () => handleJump(el)); + }); + + return () => { + links.forEach((el: Element) => { + el.removeEventListener("click", () => handleJump(el)); + }); + }; + }; + + return
; +}; diff --git a/enjoy/src/renderer/components/widgets/lookup/dict-select.tsx b/enjoy/src/renderer/components/widgets/lookup/dict-select.tsx new file mode 100644 index 00000000..e5729b2a --- /dev/null +++ b/enjoy/src/renderer/components/widgets/lookup/dict-select.tsx @@ -0,0 +1,32 @@ +import { useContext } from "react"; +import { DictProviderContext } from "@renderer/context"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@renderer/components/ui"; + +export const DictSelect = () => { + const { currentDictValue, dictSelectItems, handleSetCurrentDict } = + useContext(DictProviderContext); + + return ( + + ); +}; diff --git a/enjoy/src/renderer/components/widgets/lookup/index.tsx b/enjoy/src/renderer/components/widgets/lookup/index.tsx new file mode 100644 index 00000000..cde55da4 --- /dev/null +++ b/enjoy/src/renderer/components/widgets/lookup/index.tsx @@ -0,0 +1,5 @@ +export * from "./ai-lookup-result"; +export * from "./camdict-lookup-result"; +export * from "./dict-lookup-result"; +export * from "./dict-select"; +export * from "./lookup-widget"; diff --git a/enjoy/src/renderer/components/widgets/lookup/lookup-widget.tsx b/enjoy/src/renderer/components/widgets/lookup/lookup-widget.tsx new file mode 100644 index 00000000..4b1e4f41 --- /dev/null +++ b/enjoy/src/renderer/components/widgets/lookup/lookup-widget.tsx @@ -0,0 +1,165 @@ +import { useEffect, useContext, useState } from "react"; +import { + AppSettingsProviderContext, + DictProviderContext, +} from "@renderer/context"; +import { + Button, + Popover, + PopoverAnchor, + PopoverContent, + ScrollArea, +} from "@renderer/components/ui"; +import { + DictLookupResult, + DictSelect, + AiLookupResult, +} from "@renderer/components"; +import { ChevronLeft, ChevronFirst } from "lucide-react"; + +export const LookupWidget = () => { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { currentDictValue } = useContext(DictProviderContext); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState<{ + word: string; + context?: string; + sourceType?: string; + sourceId?: string; + position: { + x: number; + y: number; + }; + }>(); + const [history, setHistory] = useState([]); + const [current, setCurrent] = useState(""); + + const handleSelectionChanged = ( + _word: string, + _context: string, + position: { x: number; y: number } + ) => { + let word = _word; + let context = _context; + + if (word) { + if (word.indexOf(" ") > -1) return; + setSelected({ word, context, position }); + } else { + const selection = document.getSelection(); + if (!selection?.anchorNode?.parentElement) return; + + word = selection + .toString() + .trim() + .replace(/[.,/#!$%^&*;:{}=\-_`~()]+$/, ""); + + if (!word) return; + // can only lookup single word + if (word.indexOf(" ") > -1) return; + + context = selection?.anchorNode.parentElement + .closest(".sentence, h2, p, div") + ?.textContent?.trim(); + + const sourceType = selection?.anchorNode.parentElement + .closest("[data-source-type]") + ?.getAttribute("data-source-type"); + const sourceId = selection?.anchorNode.parentElement + .closest("[data-source-id]") + ?.getAttribute("data-source-id"); + + setSelected({ word, context, position, sourceType, sourceId }); + } + + handleLookup(word); + setOpen(true); + }; + + useEffect(() => { + EnjoyApp.onLookup((_event, selection, context, position) => { + handleSelectionChanged(selection, context, position); + }); + + return () => EnjoyApp.offLookup(); + }, []); + + function handleLookup(word: string) { + setCurrent(word); + setHistory([...history, word]); + } + + function handleViewFirst() { + setCurrent(history[0]); + setHistory(history.slice(0, 1)); + } + + function handleViewLast() { + setCurrent(history[history.length - 2]); + setHistory(history.slice(0, -1)); + } + + return ( + + + + {selected?.word && ( + +
+
+
+ {history.length > 1 && ( +
+ + + +
+ )} + +
{current}
+
+ +
+ +
+
+
+ {currentDictValue === "ai" ? ( + + ) : ( + + )} +
+
+
+ )} +
+
+ ); +}; diff --git a/enjoy/src/renderer/components/widgets/vocabulary.tsx b/enjoy/src/renderer/components/widgets/vocabulary.tsx new file mode 100644 index 00000000..ec95e60b --- /dev/null +++ b/enjoy/src/renderer/components/widgets/vocabulary.tsx @@ -0,0 +1,38 @@ +import React, { useContext } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; + +export const Vocabulary = ({ + word, + context, + children, +}: { + word: string; + context?: string; + children?: React.ReactNode; +}) => { + let timeout: ReturnType; + + const { vocabularyConfig, EnjoyApp } = useContext(AppSettingsProviderContext); + + const handleMouseEnter = (e: React.MouseEvent) => { + timeout = setTimeout(() => { + EnjoyApp.lookup(word, context, { x: e.clientX, y: e.clientY }); + }, 500); + }; + + const handleMouseLeave = () => { + clearTimeout(timeout); + }; + + return vocabularyConfig.lookupOnMouseOver ? ( + + {word || children} + + ) : ( + {word || children} + ); +}; diff --git a/enjoy/src/renderer/context/app-settings-provider.tsx b/enjoy/src/renderer/context/app-settings-provider.tsx index 35a58749..1530a6a7 100644 --- a/enjoy/src/renderer/context/app-settings-provider.tsx +++ b/enjoy/src/renderer/context/app-settings-provider.tsx @@ -27,6 +27,8 @@ type AppSettingsProviderState = { switchLearningLanguage?: (lang: string) => void; proxy?: ProxyConfigType; setProxy?: (config: ProxyConfigType) => Promise; + vocabularyConfig?: VocabularyConfigType; + setVocabularyConfig?: (config: VocabularyConfigType) => Promise; cable?: Consumer; ahoy?: typeof ahoy; recorderConfig?: RecorderConfigType; @@ -58,6 +60,8 @@ export const AppSettingsProvider = ({ const [language, setLanguage] = useState<"en" | "zh-CN">(); const [nativeLanguage, setNativeLanguage] = useState("zh-CN"); const [learningLanguage, setLearningLanguage] = useState("en-US"); + const [vocabularyConfig, setVocabularyConfig] = + useState(null); const [proxy, setProxy] = useState(); const EnjoyApp = window.__ENJOY_APP__; const [recorderConfig, setRecorderConfig] = useState(); @@ -202,12 +206,23 @@ export const AppSettingsProvider = ({ }); }; + const fetchVocabularyConfig = async () => { + const config = await EnjoyApp.settings.getVocabularyConfig(); + config && setVocabularyConfig(config); + }; + + const setVocabularyConfigHandler = async (config: VocabularyConfigType) => { + await EnjoyApp.settings.setVocabularyConfig(config); + setVocabularyConfig(config); + }; + useEffect(() => { fetchVersion(); fetchUser(); fetchLibraryPath(); fetchLanguages(); fetchProxyConfig(); + fetchVocabularyConfig(); initSentry(); fetchRecorderConfig(); }, []); @@ -261,6 +276,8 @@ export const AppSettingsProvider = ({ setLibraryPath: setLibraryPathHandler, proxy, setProxy: setProxyConfigHandler, + vocabularyConfig, + setVocabularyConfig: setVocabularyConfigHandler, initialized: Boolean(user && libraryPath), ahoy, cable, diff --git a/enjoy/src/renderer/context/dict-provider.tsx b/enjoy/src/renderer/context/dict-provider.tsx new file mode 100644 index 00000000..a2f1ce9a --- /dev/null +++ b/enjoy/src/renderer/context/dict-provider.tsx @@ -0,0 +1,176 @@ +import { createContext, useState, useEffect, useContext, useMemo } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { t } from "i18next"; + +type DictProviderState = { + settings: DictSettingType; + dicts: Dict[]; + downloadingDicts: Dict[]; + uninstallDicts: Dict[]; + installedDicts: Dict[]; + dictSelectItems: { text: string; value: string }[]; + reload?: () => void; + remove?: (v: Dict) => void; + removed?: (v: Dict) => void; + currentDict?: Dict | null; + currentDictValue?: string; + handleSetCurrentDict?: (v: string) => void; + setDefault?: (v: Dict) => Promise; +}; + +const AIDict = { + text: t("aiLookup"), + value: "ai", +}; + +const initialState: DictProviderState = { + dicts: [], + downloadingDicts: [], + uninstallDicts: [], + installedDicts: [], + dictSelectItems: [AIDict], + settings: { + default: "", + removing: [], + }, +}; + +export const DictProviderContext = + createContext(initialState); + +export const DictProvider = ({ children }: { children: React.ReactNode }) => { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const [dicts, setDicts] = useState([]); + const [settings, setSettings] = useState({ + default: "", + removing: [], + }); + const [currentDictValue, setCurrentDictValue] = useState(""); + const [currentDict, setCurrentDict] = useState(); + + const availableDicts = useMemo( + () => + dicts.filter((dict) => { + return ( + dict.state === "installed" && + !settings.removing?.find((v) => v === dict.name) + ); + }), + [dicts, settings] + ); + + const dictSelectItems = useMemo(() => { + return [ + AIDict, + ...availableDicts.map((item) => ({ + text: item.title, + value: item.name, + })), + ]; + }, [availableDicts]); + + const downloadingDicts = useMemo(() => { + return dicts.filter( + (dict) => dict.state === "downloading" || dict.state === "decompressing" + ); + }, [dicts]); + + const uninstallDicts = useMemo(() => { + return dicts.filter((dict) => dict.state === "uninstall"); + }, [dicts]); + + const installedDicts = useMemo(() => { + return dicts.filter((dict) => dict.state === "installed"); + }, [dicts]); + + useEffect(() => { + if (availableDicts.length) { + const _currentDict = availableDicts.find( + (dict) => dict.name === settings.default + ); + + if (_currentDict) { + handleSetCurrentDict(_currentDict.name); + } else { + setDefault(availableDicts[0]); + } + } else { + setCurrentDictValue(AIDict.value); + } + }, [availableDicts, settings]); + + useEffect(() => { + fetchSettings(); + fetchDicts(); + }, []); + + const fetchSettings = async () => { + return EnjoyApp.settings.getDictSettings().then((res) => { + res && setSettings(res); + }); + }; + + const fetchDicts = async () => { + return EnjoyApp.dict.getDicts().then((dicts) => { + setDicts(dicts); + }); + }; + + const handleSetCurrentDict = (value: string) => { + setCurrentDictValue(value); + + const dict = dicts.find((dict) => dict.name === value); + if (dict) setCurrentDict(dict); + }; + + const setDefault = async (dict: Dict) => { + const _settings = { ...settings, default: dict?.name ?? "" }; + + EnjoyApp.settings + .setDictSettings(_settings) + .then(() => setSettings(_settings)); + }; + + const remove = (dict: Dict) => { + if (!settings.removing?.find((name) => dict.name === name)) { + const removing = [...(settings.removing ?? []), dict.name]; + const _settings = { ...settings, removing }; + + EnjoyApp.settings + .setDictSettings(_settings) + .then(() => setSettings(_settings)); + } + }; + + const removed = (dict: Dict) => { + const removing = + settings.removing?.filter((name) => name !== dict.name) ?? []; + const _settings = { ...settings, removing }; + + EnjoyApp.settings + .setDictSettings(_settings) + .then(() => setSettings(_settings)); + }; + + return ( + + {children} + + ); +}; diff --git a/enjoy/src/renderer/context/index.ts b/enjoy/src/renderer/context/index.ts index db0976f6..0bb2ab7a 100644 --- a/enjoy/src/renderer/context/index.ts +++ b/enjoy/src/renderer/context/index.ts @@ -7,3 +7,4 @@ export * from "./db-provider"; export * from "./hotkeys-settings-provider"; export * from "./media-player-provider"; export * from "./theme-provider"; +export * from "./dict-provider"; diff --git a/enjoy/src/renderer/lib/dict.ts b/enjoy/src/renderer/lib/dict.ts new file mode 100644 index 00000000..48e752ea --- /dev/null +++ b/enjoy/src/renderer/lib/dict.ts @@ -0,0 +1,131 @@ +import * as cheerio from "cheerio"; + +const MIME: Record = { + css: "text/css", + img: "image", + jpg: "image/jpeg", + png: "image/png", + spx: "audio/x-speex", + wav: "audio/wav", + mp3: "audio/mp3", + js: "text/javascript", +}; + +export class DictDefinitionNormalizer { + $: cheerio.CheerioAPI; + urls: string[] = []; + onInjectScript: (url: string) => void; + onReadResource: (key: string) => Promise; + + constructor({ + onInjectScript, + onReadResource, + }: { + onInjectScript: (url: string) => void; + onReadResource: (key: string) => Promise; + }) { + this.onInjectScript = onInjectScript; + this.onReadResource = onReadResource; + } + + async createUrl(mime: string, data: string) { + const resp = await fetch(`data:${mime};base64,${data}`); + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + + this.urls.push(url); + + return url; + } + + async normalizeImages() { + return Promise.all( + this.$("img[src]") + .toArray() + .map(async (img) => { + const $img = this.$(img); + const src = $img.attr("src"); + const paths = /^file:\/\/(.*)/.exec(src); + const key = paths ? paths[1] : src; + const data = await this.onReadResource(key); + const url = await this.createUrl(MIME["img"], data); + + $img.attr("src", url).attr("_src", src); + }) + ); + } + + async normalizeStyles() { + return Promise.all( + this.$("link[rel=stylesheet]") + .toArray() + .map(async (link) => { + const $link = this.$(link); + const data = await this.onReadResource($link.attr("href")); + const url = await this.createUrl(MIME["css"], data); + + $link.replaceWith( + this.$("