commit 72d7a750a2053505861d0f681bb895c6265b77e8 Author: 黄志军 <851516902@qq.com> Date: Sat May 16 23:24:55 2026 +0800 初始化web版本MyReader diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b96e3fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.DS_Store +*.log +.env +.env.* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/MyReader-Web.iml b/.idea/MyReader-Web.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/MyReader-Web.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e9710cf --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..b5cd114 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/protoPluginMemos.xml b/.idea/protoPluginMemos.xml new file mode 100644 index 0000000..0e30558 --- /dev/null +++ b/.idea/protoPluginMemos.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..44cbbc0 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Huangzhijun's Reader — Web 版(MyReader-Web) + +本工程是桌面版 **[MyReader](../MyReader)** 的 **1:1 功能 Web 移植**,同一套 React 界面与 Markdown 能力,在浏览器中运行。桌面版基于 Electron,本工程基于 **Vite + React**,通过浏览器 **File System Access API**(及降级方案)替代 Electron 的本地文件 IPC。 + +## 与 MyReader 的对应关系 + +| 能力 | MyReader (Electron) | MyReader-Web | +|------|---------------------|--------------| +| 多标签、脏标记、关闭前保存提示 | ✅ | ✅ | +| 编辑 / 阅读 / 分栏三视图 | ✅ | ✅ | +| 侧栏:文件树、历史、大纲 | ✅ | ✅ | +| 工具栏:打开/保存/另存为/新标签/视图切换 | ✅ | ✅ | +| 背景 23 种、字体 32 种、阅读行距 | ✅ | ✅ | +| CodeMirror 编辑 + 格式工具栏 | ✅ | ✅ | +| Markdown 预览、GFM 表格、代码高亮 | ✅ | ✅ | +| Mermaid / 流程图 | ✅ | ✅ | +| 本地图片插入与预览 | `file://` | `blob:`(浏览器) | +| 会话恢复(localStorage) | ✅ | ✅(路径需仍可访问) | +| 原生菜单与快捷键 | 系统菜单 | **Ctrl 组合键**(见下) | +| 安装包 / 系统托盘 | ✅ | ❌ | + +## 设计思路 + +1. **UI 与逻辑复用** + 自 `MyReader/src/renderer/src` 复制全部前端模块(`App.tsx`、`EditorPane`、`markdown.ts`、`PreviewArticle` 等),保证交互与桌面版一致。 + +2. **平台适配层** + `src/platform/browserApi.ts` 实现与 Electron `preload` 相同的 `window.myreader` 接口;`src/platform/webVfs.ts` 用虚拟路径(如 `/项目名/笔记.md`)映射 `FileSystemDirectoryHandle` / `FileSystemFileHandle`。 + +3. **启动注入** + `main.tsx` 在渲染前调用 `installBrowserApi()`,无需 Electron。 + +4. **安全与能力边界** + 浏览器无法任意访问磁盘;须用户通过「打开」「打开文件夹」授权。未授权路径无法读写。推荐使用 **Chrome / Edge 桌面版** 以获得完整文件夹 API。 + +## 环境要求 + +- **Node.js** ≥ 20.10 +- 现代浏览器(推荐 Chrome / Edge 最新版) + +## 安装与运行 + +```bash +cd F:\coding\MyReader-Web +npm install +npm run dev +``` + +开发服务器默认端口 **1234**。若被占用,Vite 会自动尝试下一可用端口,终端会打印实际地址(例如 `http://localhost:1235`)。 + +生产构建与本地预览: + +```bash +npm run build +npm run preview +``` + +`npm run preview` 同样优先使用 1234 端口(占用时自动递增)。 + +## 使用步骤 + +1. 执行 `npm run dev`,浏览器打开提示的本地地址。 +2. 点击 **「打开文件夹」**(侧栏)或工具栏 **「打开」** 选择 Markdown 文件。 +3. 使用 **「视图」** 或 `Ctrl+E` 在编辑 / 阅读 / 分栏间切换。 +4. 调整工具栏右侧 **行距、字体、背景**。 +5. **保存** / **另存为** 在已授权文件上写回磁盘;新文件通过保存对话框创建。 +6. 插入图片时使用工具栏;Web 版以 **blob URL** 嵌入,请与 `.md` 同目录保存图片或改用相对路径 + 已打开文件夹。 + +## 快捷键(Web) + +| 快捷键 | 功能 | +|--------|------| +| Ctrl+O | 打开文件 | +| Ctrl+S | 保存 | +| Ctrl+Shift+S | 另存为 | +| Ctrl+T | 新标签 | +| Ctrl+W | 关闭标签 | +| Ctrl+E | 切换视图模式 | +| Ctrl+Tab | 下一标签 | +| Ctrl+Shift+Tab | 上一标签 | + +## 项目结构 + +``` +MyReader-Web/ +├── index.html +├── vite.config.ts # 端口 1234(可自动换端口) +├── package.json +├── public/ +└── src/ + ├── App.tsx # 主界面(与 MyReader 同源) + ├── platform/ + │ ├── browserApi.ts # window.myreader 浏览器实现 + │ └── webVfs.ts # 虚拟路径 + 文件句柄 + ├── types/ipc-types.ts + └── … # 其余与 MyReader 渲染层一致 +``` + +## 已知差异(相对桌面版) + +- 无系统菜单栏;依赖页面内按钮与上表快捷键。 +- 关闭浏览器标签时,若有未保存内容,使用 `beforeunload` 提示(行为因浏览器而异)。 +- 会话恢复中的路径若文件夹未再次授权,可能无法自动重新打开。 +- Safari / Firefox 对 `showDirectoryPicker` 支持有限,文件夹浏览可能不可用。 +- 内链打开本地非 Markdown 文件、任意 `file://` 图片等仍受浏览器安全策略限制。 + +## 维护说明 + +桌面版功能更新后,可将 `MyReader/src/renderer/src` 下同名文件同步复制到本工程 `src/`,并检查是否新增 `window.myreader` 接口——若有,须在 `platform/browserApi.ts` 中一并实现。 + +--- + +**显示名称**:Huangzhijun's Reader (Web) +**同源项目**:`F:\coding\MyReader` diff --git a/index.html b/index.html new file mode 100644 index 0000000..a10798b --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Huangzhijun's Reader (Web) + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..981c8bc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3289 @@ +{ + "name": "myreader-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "myreader-web", + "version": "0.1.0", + "dependencies": { + "@codemirror/commands": "^6.8.0", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/language": "^6.11.0", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.4", + "dompurify": "^3.2.5", + "highlight.js": "^11.11.1", + "markdown-it": "^14.1.0", + "markdown-it-multimd-table": "^4.2.3", + "mermaid": "^11.15.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5.8.3", + "vite": "^6.3.4" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.2", + "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", + "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.6", + "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.6.tgz", + "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmmirror.com/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.1.1.tgz", + "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.1" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.33.3", + "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz", + "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmmirror.com/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmmirror.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.3.tgz", + "integrity": "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.356", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", + "integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/katex": { + "version": "0.16.46", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.46.tgz", + "integrity": "sha512-WHy4Coo+bGZyH7NwJKHkS04YFsFcarWbAEOAC3EMndzdN6VSZqklLLIgfxzyaW9jDoeGYJX9SWbJPKpecox0Uw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-multimd-table": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/markdown-it-multimd-table/-/markdown-it-multimd-table-4.2.3.tgz", + "integrity": "sha512-KepCr2OMJqm7IT6sOIbuqHGe+NERhgy66XMrc5lo6dHW7oaPzMDtYwR1EGwK16/blb6mCSg4jqityOe0o/H7HA==", + "license": "MIT" + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/mermaid": { + "version": "11.15.0", + "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "es-toolkit": "^1.45.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7c9a4ea --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "myreader-web", + "version": "0.1.0", + "private": true, + "description": "Huangzhijun's Reader — Web edition (browser port of MyReader)", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codemirror/commands": "^6.8.0", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/language": "^6.11.0", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.4", + "dompurify": "^3.2.5", + "highlight.js": "^11.11.1", + "markdown-it": "^14.1.0", + "markdown-it-multimd-table": "^4.2.3", + "mermaid": "^11.15.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5.8.3", + "vite": "^6.3.4" + } +} diff --git a/public/app-icon.png b/public/app-icon.png new file mode 100644 index 0000000..004eb60 Binary files /dev/null and b/public/app-icon.png differ diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..3806efe --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,1490 @@ +import * as React from 'react' +import { EditorPane, type MarkdownEditorHandle } from './EditorPane' +import type { EditorView } from '@codemirror/view' +import { MarkdownFormatToolbar } from './MarkdownFormatToolbar' +import { OutlinePanel } from './OutlinePanel' +import { PreviewArticle } from './PreviewArticle' +import { + extractOutline, + previewLineCenterInShell, + renderMarkdown, + scrollPreviewToSourceLine, + type OutlineItem +} from './markdown' +import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance' +import { COLOR_SWATCH_ROWS } from './colorPresets' +import { registerPathPrompt } from './platform/pathPrompt' +import { + downloadTextFile, + fileNameFromPath, + lacksFileSystemAccessContext +} from './downloadTextFile' +import { randomId } from './randomId' +import { webVfs } from './platform/webVfs' + +type Tab = { + id: string + filePath: string | null + content: string + savedContent: string +} + +const BG_KEY = 'myreader-bg-id' +const FONT_KEY = 'myreader-font-id' +const VIEW_KEY = 'myreader-view' +const LEGACY_READ_KEY = 'myreader-read' +const SESSION_KEY = 'myreader-session-v1' +const SIDEBAR_W_KEY = 'myreader-sidebar-w' +const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden' +const DESKTOP_DOWNLOAD_URL = + 'http://huangzjsecret.online/api/uploads/huangzhijun`sReader.zip' +const SPLIT_KEY = 'myreader-split' +const PREVIEW_W_KEY = 'myreader-preview-max' +const PREVIEW_LINE_KEY = 'myreader-preview-line-height' + +function loadPreviewLineHeight(): string { + const raw = localStorage.getItem(PREVIEW_LINE_KEY) + if (!raw) return '1.5' + const n = Number.parseFloat(raw) + if (!Number.isFinite(n)) return '1.5' + return String(Math.min(3, Math.max(1, n))) +} + +type SessionTabRow = { path: string | null; content: string; saved: string } +type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string } + +function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)) +} + +function normalizePathKey(p: string): string { + return p.replace(/\\/g, '/').toLowerCase() +} + +function newTab(): Tab { + const id = randomId() + return { id, filePath: null, content: '', savedContent: '' } +} + +function tabLabel(t: Tab): string { + if (t.filePath) { + const parts = t.filePath.split(/[/\\]/) + return parts[parts.length - 1] || t.filePath + } + return '未命名' +} + +function isDirty(t: Tab): boolean { + return t.content !== t.savedContent +} + +function loadViewMode(): ViewMode { + const v = localStorage.getItem(VIEW_KEY) as ViewMode | null + if (v === 'edit' || v === 'read' || v === 'hybrid') return v + if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read' + if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit' + return 'read' +} + +function viewModeLabel(m: ViewMode): string { + if (m === 'edit') return '编辑' + if (m === 'read') return '阅读' + return '分栏' +} + +export default function App(): React.ReactElement { + const api = window.myreader + const editorRef = React.useRef(null) + const [tabs, setTabs] = React.useState([]) + const [activeId, setActiveId] = React.useState('') + const [sessionHydrated, setSessionHydrated] = React.useState(false) + const [viewMode, setViewMode] = React.useState(() => loadViewMode()) + const [bgId, setBgId] = React.useState(() => { + return ( + validBackgroundId(localStorage.getItem(BG_KEY)) ?? + validBackgroundId(localStorage.getItem('myreader-theme')) ?? + validBackgroundId(localStorage.getItem('myreader-bg')) ?? + '1' + ) + }) + const [fontId, setFontId] = React.useState(() => { + return validFontId(localStorage.getItem(FONT_KEY)) ?? '0' + }) + const [previewLineHeight, setPreviewLineHeight] = React.useState(() => loadPreviewLineHeight()) + const [gotoLine, setGotoLine] = React.useState<{ line: number; seq: number } | null>(null) + const [sidebarHidden, setSidebarHidden] = React.useState( + () => localStorage.getItem(SIDEBAR_HIDDEN_KEY) === '1' + ) + const [sidebarWidth, setSidebarWidth] = React.useState(() => { + const n = Number(localStorage.getItem(SIDEBAR_W_KEY) ?? '260') + return Number.isFinite(n) ? clamp(n, 160, 560) : 260 + }) + const [splitPct, setSplitPct] = React.useState(() => { + const n = Number(localStorage.getItem(SPLIT_KEY) ?? '48') + return Number.isFinite(n) ? clamp(n, 22, 78) : 48 + }) + const [previewMaxWidth, setPreviewMaxWidth] = React.useState(() => { + const n = Number(localStorage.getItem(PREVIEW_W_KEY) ?? '880') + return Number.isFinite(n) ? clamp(n, 420, 1400) : 880 + }) + const [closePromptTabId, setClosePromptTabId] = React.useState(null) + const [textPrompt, setTextPrompt] = React.useState<{ + title: string + hint?: string + defaultValue: string + resolve: (v: string | null) => void + } | null>(null) + const [textPromptInput, setTextPromptInput] = React.useState('') + const [imageInsert, setImageInsert] = React.useState<{ + resolve: (v: { url: string; alt: string } | null) => void + } | null>(null) + const [imageUrlInput, setImageUrlInput] = React.useState('') + const [imageAltInput, setImageAltInput] = React.useState('图片') + const [colorPicker, setColorPicker] = React.useState<{ + resolve: (v: string | null) => void + } | null>(null) + const [colorHexInput, setColorHexInput] = React.useState('#dc2626') + const [webNotesOpen, setWebNotesOpen] = React.useState(false) + const [desktopDownloadOpen, setDesktopDownloadOpen] = React.useState(false) + const readPreviewOuterRef = React.useRef(null) + const readPreviewArticleRef = React.useRef(null) + const hybridPreviewRef = React.useRef(null) + const hybridPreviewShellRef = React.useRef(null) + const hybridPreviewArticleRef = React.useRef(null) + const hybridEditorViewRef = React.useRef(null) + const [hybridViewEpoch, setHybridViewEpoch] = React.useState(0) + const [hybridLinePulse, setHybridLinePulse] = React.useState<{ + topPx: number + seq: number + } | null>(null) + + const activeTab = tabs.find((t) => t.id === activeId) ?? null + const activeDoc = activeTab?.content ?? '' + const activeLineCount = React.useMemo( + () => (activeDoc ? activeDoc.split(/\r?\n/).length : 1), + [activeDoc] + ) + + const tabsRef = React.useRef(tabs) + tabsRef.current = tabs + + React.useEffect(() => { + if (!api) { + setSessionHydrated(true) + return + } + let cancelled = false + void (async () => { + try { + const raw = localStorage.getItem(SESSION_KEY) + if (!raw) return + const data = JSON.parse(raw) as Partial + if (data.v !== 1 || !Array.isArray(data.tabs)) return + const restored: Tab[] = [] + for (const row of data.tabs) { + if (!row || typeof row !== 'object') continue + const id = randomId() + const content = typeof row.content === 'string' ? row.content : '' + const saved = typeof row.saved === 'string' ? row.saved : content + if (row.path) { + try { + await api.readFile(row.path) + restored.push({ id, filePath: row.path, content, savedContent: saved }) + } catch { + /* missing or unreadable */ + } + } else { + restored.push({ id, filePath: null, content, savedContent: saved }) + } + } + if (cancelled) return + setTabs(restored) + if (restored.length) { + let idx = typeof data.activeIndex === 'number' ? data.activeIndex : 0 + if (!Number.isFinite(idx)) idx = 0 + idx = Math.max(0, Math.min(idx, restored.length - 1)) + setActiveId(restored[idx]!.id) + } + if (typeof data.bgId === 'string') { + const b = validBackgroundId(data.bgId) + if (b) setBgId(b) + } + if (typeof data.fontId === 'string') { + const f = validFontId(data.fontId) + if (f) setFontId(f) + } + } catch { + /* ignore corrupt session */ + } finally { + if (!cancelled) setSessionHydrated(true) + } + })() + return () => { + cancelled = true + } + }, [api]) + + React.useEffect(() => { + if (!sessionHydrated) return + const id = window.setTimeout(() => { + const activeIndex = + tabs.length === 0 ? 0 : Math.max(0, tabs.findIndex((t) => t.id === activeId)) + const idx = + tabs.length === 0 + ? 0 + : activeIndex < 0 + ? 0 + : Math.min(activeIndex, tabs.length - 1) + const payload: SessionV1 = { + v: 1, + activeIndex: idx, + tabs: tabs.map((t) => ({ + path: t.filePath, + content: t.content, + saved: t.savedContent + })), + bgId, + fontId + } + localStorage.setItem(SESSION_KEY, JSON.stringify(payload)) + }, 400) + return () => window.clearTimeout(id) + }, [tabs, activeId, sessionHydrated, bgId, fontId]) + + React.useEffect(() => { + localStorage.setItem(BG_KEY, bgId) + document.documentElement.dataset.bg = bgId + }, [bgId]) + + React.useEffect(() => { + localStorage.setItem(FONT_KEY, fontId) + document.documentElement.dataset.font = fontId + }, [fontId]) + + React.useEffect(() => { + localStorage.setItem(PREVIEW_LINE_KEY, previewLineHeight) + document.documentElement.style.setProperty('--preview-line-height', previewLineHeight) + }, [previewLineHeight]) + + React.useEffect(() => { + localStorage.setItem(VIEW_KEY, viewMode) + }, [viewMode]) + + React.useEffect(() => { + localStorage.setItem(SIDEBAR_HIDDEN_KEY, sidebarHidden ? '1' : '0') + }, [sidebarHidden]) + + React.useEffect(() => { + localStorage.setItem(SIDEBAR_W_KEY, String(sidebarWidth)) + }, [sidebarWidth]) + + React.useEffect(() => { + localStorage.setItem(SPLIT_KEY, String(splitPct)) + }, [splitPct]) + + React.useEffect(() => { + localStorage.setItem(PREVIEW_W_KEY, String(previewMaxWidth)) + }, [previewMaxWidth]) + + React.useEffect(() => { + if (textPrompt) setTextPromptInput(textPrompt.defaultValue) + }, [textPrompt]) + + React.useEffect(() => { + if (imageInsert) { + setImageUrlInput('') + setImageAltInput('图片') + } + }, [imageInsert]) + + React.useEffect(() => { + if (colorPicker) setColorHexInput('#dc2626') + }, [colorPicker]) + + React.useEffect(() => { + if (viewMode !== 'hybrid') setHybridLinePulse(null) + }, [viewMode]) + + const requestText = React.useCallback((title: string, defaultValue: string) => { + return new Promise((resolve) => { + setTextPrompt({ title, defaultValue, resolve }) + }) + }, []) + + const requestDiskPath = React.useCallback( + (title: string, hint: string, defaultValue: string) => { + return new Promise((resolve) => { + setTextPrompt({ title, hint, defaultValue, resolve }) + }) + }, + [] + ) + + React.useEffect(() => { + registerPathPrompt(async (req) => { + const value = await requestDiskPath(req.title, req.hint, req.defaultValue) + return value?.trim() ? value.trim() : null + }) + return () => registerPathPrompt(null) + }, [requestDiskPath]) + + const requestImageDetails = React.useCallback((): Promise<{ url: string; alt: string } | null> => { + return new Promise((resolve) => { + setImageInsert({ resolve }) + }) + }, []) + + const requestColor = React.useCallback((): Promise => { + return new Promise((resolve) => { + setColorPicker({ resolve }) + }) + }, []) + + React.useEffect(() => { + if (viewMode !== 'hybrid') return + const view = hybridEditorViewRef.current + const prevEl = hybridPreviewRef.current + if (!view || !prevEl) return + + const edScroll = view.scrollDOM + const maxScroll = (el: HTMLElement): number => Math.max(1, el.scrollHeight - el.clientHeight) + let syncing = false + + const syncFromEditor = (): void => { + if (syncing) return + syncing = true + const re = edScroll.scrollTop / maxScroll(edScroll) + prevEl.scrollTop = re * maxScroll(prevEl) + requestAnimationFrame(() => { + syncing = false + }) + } + const syncFromPreview = (): void => { + if (syncing) return + syncing = true + const rp = prevEl.scrollTop / maxScroll(prevEl) + edScroll.scrollTop = rp * maxScroll(edScroll) + requestAnimationFrame(() => { + syncing = false + }) + } + + edScroll.addEventListener('scroll', syncFromEditor, { passive: true }) + prevEl.addEventListener('scroll', syncFromPreview, { passive: true }) + requestAnimationFrame(() => { + syncFromEditor() + }) + return () => { + edScroll.removeEventListener('scroll', syncFromEditor) + prevEl.removeEventListener('scroll', syncFromPreview) + } + }, [viewMode, hybridViewEpoch, activeId]) + + React.useEffect(() => { + if (viewMode !== 'hybrid') return + const view = hybridEditorViewRef.current + const prevEl = hybridPreviewRef.current + if (!view || !prevEl) return + requestAnimationFrame(() => { + const edScroll = view.scrollDOM + const maxE = Math.max(1, edScroll.scrollHeight - edScroll.clientHeight) + const maxP = Math.max(1, prevEl.scrollHeight - prevEl.clientHeight) + const r = edScroll.scrollTop / maxE + prevEl.scrollTop = r * maxP + }) + }, [viewMode, activeDoc]) + + const onHybridViewReady = React.useCallback((view: EditorView | null) => { + hybridEditorViewRef.current = view + setHybridViewEpoch((e) => e + 1) + }, []) + + const onHybridCursorLine = React.useCallback( + (line: number) => { + requestAnimationFrame(() => { + const article = hybridPreviewArticleRef.current + const shell = hybridPreviewShellRef.current + if (!article || !shell) return + const topPx = previewLineCenterInShell(article, shell, line, activeLineCount) + if (topPx === null) return + setHybridLinePulse({ topPx, seq: Date.now() }) + }) + }, + [activeLineCount] + ) + + const navigateToOutline = React.useCallback( + (item: OutlineItem) => { + const line = item.line + if (viewMode === 'read') { + requestAnimationFrame(() => { + const scrollEl = readPreviewOuterRef.current + const article = readPreviewArticleRef.current + if (scrollEl && article) { + scrollPreviewToSourceLine(scrollEl, article, line, 'smooth', activeLineCount) + } + }) + return + } + setGotoLine({ line, seq: Date.now() }) + if (viewMode === 'hybrid') { + requestAnimationFrame(() => { + const scrollEl = hybridPreviewRef.current + const article = hybridPreviewArticleRef.current + const shell = hybridPreviewShellRef.current + if (scrollEl && article) { + scrollPreviewToSourceLine(scrollEl, article, line, 'smooth', activeLineCount) + } + if (article && shell) { + const topPx = previewLineCenterInShell(article, shell, line, activeLineCount) + if (topPx !== null) setHybridLinePulse({ topPx, seq: Date.now() }) + } + }) + } + }, + [viewMode, activeLineCount] + ) + + const outline = React.useMemo(() => extractOutline(activeDoc), [activeDoc]) + + const previewHtml = React.useMemo( + () => renderMarkdown(activeDoc, { baseFilePath: activeTab?.filePath ?? null }), + [activeDoc, activeTab?.filePath] + ) + + const pickLocalImage = React.useCallback(async (): Promise => { + if (!api) return + const overlay = document.querySelector('.modal-overlay') + if (overlay) overlay.style.visibility = 'hidden' + try { + const r = await api.openImageDialog() + if (!r.canceled && r.url) setImageUrlInput(r.url) + } finally { + if (overlay) overlay.style.visibility = '' + } + }, [api]) + + const updateActive = React.useCallback( + (fn: (t: Tab) => Tab): void => { + if (!activeId) return + setTabs((prev) => prev.map((t) => (t.id === activeId ? fn(t) : t))) + }, + [activeId] + ) + + const onEditorChange = React.useCallback( + (next: string) => { + updateActive((t) => ({ ...t, content: next })) + }, + [updateActive] + ) + + const editorMountKey = activeTab ? `${activeTab.id}-${viewMode}` : `empty-${viewMode}` + + const scrollPreviewToAnchorId = React.useCallback( + (anchorId: string, mode: ViewMode = viewMode): void => { + const id = decodeURIComponent(anchorId) + if (!id) return + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const article = + mode === 'read' ? readPreviewArticleRef.current : hybridPreviewArticleRef.current + if (!article) return + let el: Element | null = null + try { + el = article.querySelector(`#${CSS.escape(id)}`) + } catch { + el = article.querySelector(`#${id}`) + } + if (!el) el = document.getElementById(id) + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }) + }) + }, + [viewMode] + ) + + const openPathInTab = React.useCallback( + async (path: string, anchorId?: string): Promise => { + if (!api) return + try { + const key = normalizePathKey(path) + const existing = tabsRef.current.find( + (t) => t.filePath && normalizePathKey(t.filePath) === key + ) + if (existing) { + setActiveId(existing.id) + if (anchorId) scrollPreviewToAnchorId(anchorId) + return + } + const content = await api.readFile(path) + const nt: Tab = { id: randomId(), filePath: path, content, savedContent: content } + setTabs((prev) => [...prev, nt]) + setActiveId(nt.id) + setViewMode('read') + if (anchorId) scrollPreviewToAnchorId(anchorId, 'read') + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + window.alert(`无法打开文件:${msg}`) + } + }, + [api, scrollPreviewToAnchorId] + ) + + const openFileDialog = React.useCallback(async (): Promise => { + if (!api) return + const r = await api.openFileDialog() + if (r.canceled || !r.path || r.content === undefined) return + const existing = tabsRef.current.find( + (t) => r.path && t.filePath && normalizePathKey(t.filePath) === normalizePathKey(r.path) + ) + if (existing) { + setActiveId(existing.id) + return + } + setViewMode('read') + const nt: Tab = { + id: randomId(), + filePath: r.path, + content: r.content, + savedContent: r.content + } + setTabs((prev) => [...prev, nt]) + setActiveId(nt.id) + }, [api]) + + const [saveToast, setSaveToast] = React.useState(null) + + React.useEffect(() => { + if (!saveToast) return + const timer = window.setTimeout(() => setSaveToast(null), 2800) + return () => clearTimeout(timer) + }, [saveToast]) + + const markTabSaved = React.useCallback((tabId: string, filePath?: string | null): void => { + setTabs((prev) => + prev.map((x) => + x.id === tabId + ? { + ...x, + ...(filePath !== undefined ? { filePath } : {}), + savedContent: x.content + } + : x + ) + ) + }, []) + + const persistSave = React.useCallback( + async (t: Tab, filePath: string): Promise => { + await api!.writeFile(filePath, t.content) + const memoryOnly = webVfs.isMemoryOnlyPath(filePath) + if (memoryOnly || lacksFileSystemAccessContext()) { + downloadTextFile(fileNameFromPath(filePath), t.content) + setSaveToast( + memoryOnly + ? '已保存:内容已更新,并已触发下载(浏览器无法直接写回您电脑上的原文件)' + : '已保存:已触发下载' + ) + } else { + setSaveToast('已保存到文件') + } + }, + [api] + ) + + const saveTabById = React.useCallback( + async (tabId: string): Promise => { + if (!api) return false + const t = tabsRef.current.find((x) => x.id === tabId) + if (!t) return false + try { + if (!t.filePath) { + const d = await api.saveFileDialog() + if (d.canceled || !d.path) { + if (lacksFileSystemAccessContext()) { + downloadTextFile('未命名.md', t.content) + markTabSaved(tabId) + setSaveToast('已保存:已下载「未命名.md」') + return true + } + return false + } + await persistSave(t, d.path) + markTabSaved(tabId, d.path) + return true + } + await persistSave(t, t.filePath) + markTabSaved(tabId) + return true + } catch (e) { + console.error('[MyReader] saveTabById', e) + const msg = e instanceof Error ? e.message : String(e) + if (t.filePath && (lacksFileSystemAccessContext() || webVfs.isMemoryOnlyPath(t.filePath))) { + downloadTextFile(fileNameFromPath(t.filePath), t.content) + markTabSaved(tabId) + setSaveToast('已保存:已触发下载') + return true + } + window.alert(`保存失败:${msg}`) + return false + } + }, + [api, markTabSaved, persistSave] + ) + + const saveActive = React.useCallback(async (): Promise => { + if (!activeId) { + window.alert('请先选择要保存的标签页') + return + } + await saveTabById(activeId) + }, [activeId, saveTabById]) + + const saveActiveAs = React.useCallback(async (): Promise => { + if (!api) return + const t = tabs.find((x) => x.id === activeId) + if (!t) { + window.alert('请先选择要保存的标签页') + return + } + try { + const d = await api.saveFileDialog(t.filePath ?? undefined) + if (d.canceled || !d.path) { + if (lacksFileSystemAccessContext()) { + downloadTextFile(fileNameFromPath(t.filePath), t.content) + markTabSaved(activeId) + setSaveToast('已另存为:已触发下载') + } + return + } + await persistSave(t, d.path) + markTabSaved(activeId, d.path) + } catch (e) { + console.error('[MyReader] saveActiveAs', e) + const msg = e instanceof Error ? e.message : String(e) + if (lacksFileSystemAccessContext()) { + downloadTextFile(fileNameFromPath(t.filePath), t.content) + markTabSaved(activeId) + setSaveToast('已另存为:已触发下载') + return + } + window.alert(`另存为失败:${msg}`) + } + }, [api, activeId, tabs, markTabSaved, persistSave]) + + const removeTabById = React.useCallback((tabId: string): void => { + setTabs((prev) => { + if (prev.length <= 1) { + setActiveId('') + return [] + } + const idx = prev.findIndex((x) => x.id === tabId) + if (idx < 0) return prev + const next = prev.filter((x) => x.id !== tabId) + const pick = next[Math.min(idx, next.length - 1)]! + setActiveId(pick.id) + return next + }) + }, []) + + const requestCloseTab = React.useCallback( + (tabId: string): void => { + const t = tabsRef.current.find((x) => x.id === tabId) + if (!t) return + if (!isDirty(t)) { + removeTabById(tabId) + return + } + setClosePromptTabId(tabId) + }, + [removeTabById] + ) + + const newTabAction = React.useCallback((): void => { + setViewMode('edit') + const nt = newTab() + setTabs((prev) => [...prev, nt]) + setActiveId(nt.id) + }, []) + + const closeTabAction = React.useCallback((): void => { + requestCloseTab(activeId) + }, [activeId, requestCloseTab]) + + const tabNext = React.useCallback((): void => { + if (tabs.length === 0) return + const idx = tabs.findIndex((x) => x.id === activeId) + const n = tabs[(idx + 1) % tabs.length] + if (n) setActiveId(n.id) + }, [activeId, tabs]) + + const tabPrev = React.useCallback((): void => { + if (tabs.length === 0) return + const idx = tabs.findIndex((x) => x.id === activeId) + const n = tabs[(idx - 1 + tabs.length) % tabs.length] + if (n) setActiveId(n.id) + }, [activeId, tabs]) + + const cycleViewMode = React.useCallback((): void => { + setViewMode((m) => (m === 'edit' ? 'read' : m === 'read' ? 'hybrid' : 'edit')) + }, []) + + const startSidebarResize = React.useCallback((e: React.MouseEvent): void => { + e.preventDefault() + const startX = e.clientX + const startW = sidebarWidth + const onMove = (ev: MouseEvent): void => { + setSidebarWidth(clamp(startW + (ev.clientX - startX), 160, 560)) + } + const onUp = (): void => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + }, [sidebarWidth]) + + const startHybridSplitResize = React.useCallback((e: React.MouseEvent): void => { + e.preventDefault() + const shell = (e.currentTarget as HTMLElement).closest('.hybrid-row') + if (!shell) return + const onMove = (ev: MouseEvent): void => { + const rect = shell.getBoundingClientRect() + const x = ev.clientX - rect.left + const pct = clamp((x / Math.max(1, rect.width)) * 100, 22, 78) + setSplitPct(pct) + } + const onUp = (): void => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + }, []) + + const startPreviewWidthResize = React.useCallback( + (e: React.MouseEvent): void => { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startW = previewMaxWidth + const onMove = (ev: MouseEvent): void => { + setPreviewMaxWidth(clamp(startW + (ev.clientX - startX), 420, 1400)) + } + const onUp = (): void => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + }, + [previewMaxWidth] + ) + + const handlersRef = React.useRef({ + openFileDialog: async (): Promise => {}, + saveActive: async (): Promise => {}, + saveActiveAs: async (): Promise => {}, + newTabAction: (): void => {}, + closeTabAction: (): void => {}, + tabNext: (): void => {}, + tabPrev: (): void => {} + }) + handlersRef.current = { + openFileDialog, + saveActive, + saveActiveAs, + newTabAction, + closeTabAction, + tabNext, + tabPrev + } + + React.useEffect(() => { + if (!api) return + const unsubClose = api.onRequestClose(() => { + try { + const anyDirty = tabsRef.current.some(isDirty) + if (!anyDirty) { + api.allowClose() + return + } + const ok = window.confirm('有未保存的标签,确定退出并丢弃更改?') + if (ok) api.allowClose() + } catch (e) { + console.error('[MyReader] request-close handler', e) + api.allowClose() + } + }) + const unsubMenu = api.onMenuAction((action) => { + void (async () => { + try { + const h = handlersRef.current + switch (action) { + case 'file-open': + await h.openFileDialog() + break + case 'file-save': + await h.saveActive() + break + case 'file-save-as': + await h.saveActiveAs() + break + case 'tab-new': + h.newTabAction() + break + case 'tab-close': + h.closeTabAction() + break + case 'tab-next': + h.tabNext() + break + case 'tab-prev': + h.tabPrev() + break + case 'toggle-read': + cycleViewMode() + break + case 'help-about': + window.alert( + " Huangzhijun's Reader 0.1 — 本地 Markdown 阅读与编辑 \n \n 作者:黄志军 \n 支持与协助(wx):liaofan199404" + ) + break + default: + break + } + } catch (e) { + console.error('[MyReader] menu action', action, e) + } + })() + }) + return () => { + unsubClose() + unsubMenu() + } + }, [api, cycleViewMode]) + + if (!api) { + return ( +
+

浏览器文件接口未就绪,请刷新页面重试。

+
+ ) + } + + const renderPreviewArticle = (articleRef?: React.Ref): React.ReactElement => ( + api.resolveRelativePath(base, href)} + onOpenMarkdown={(path, anchorId) => openPathInTab(path, anchorId)} + onOpenExternal={(url) => api.openExternal(url)} + /> + ) + + const closePromptTab = closePromptTabId + ? tabsRef.current.find((t) => t.id === closePromptTabId) + : undefined + + return ( +
+ {closePromptTabId && closePromptTab ? ( +
setClosePromptTabId(null)} + > +
e.stopPropagation()} + > +

关闭标签

+

+ 「{tabLabel(closePromptTab)}」有未保存的更改,是否保存? +

+
+ + + +
+
+
+ ) : null} + + {textPrompt ? ( +
{ + if (e.target === e.currentTarget) { + textPrompt.resolve(null) + setTextPrompt(null) + } + }} + > +
e.stopPropagation()} + > +

{textPrompt.title}

+ {textPrompt.hint ?

{textPrompt.hint}

: null} + setTextPromptInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + textPrompt.resolve(textPromptInput) + setTextPrompt(null) + } + if (e.key === 'Escape') { + e.preventDefault() + textPrompt.resolve(null) + setTextPrompt(null) + } + }} + autoFocus + /> +
+ + +
+
+
+ ) : null} + + {imageInsert ? ( +
{ + if (e.target === e.currentTarget) { + imageInsert.resolve(null) + setImageInsert(null) + } + }} + > +
e.stopPropagation()} + > +

插入图片

+

支持网络地址或本地图片文件(Markdown 中保存为 file:// 链接)。

+ +
+ +
+ +
+ + +
+
+
+ ) : null} + + {colorPicker ? ( +
{ + if (e.target === e.currentTarget) { + colorPicker.resolve(null) + setColorPicker(null) + } + }} + > +
e.stopPropagation()} + > +

文字颜色

+
+ 取色器 + setColorHexInput(e.target.value.toLowerCase())} + aria-label="色谱" + /> +
+ +
+ {COLOR_SWATCH_ROWS.map((row, ri) => ( +
+ {row.map((hex) => ( +
+ ))} +
+
+ + +
+
+
+ ) : null} + + {webNotesOpen ? ( +
setWebNotesOpen(false)} + > +
e.stopPropagation()} + > +

+ Web 版额外说明 +

+
+

+ 在浏览器中运行,读写的是您本机通过「打开 / 保存」授权的文件,不会访问服务器磁盘。需要完整本地编辑体验请使用工具栏「桌面版下载地址」。 +

+

+ 打开与保存:工具栏「打开」选择本地 Markdown;「保存」写回原文件(需浏览器支持 + File System Access API);「另存为」可保存到新位置。推荐使用 Chrome / Edge 桌面版,并通过 + HTTPS 访问以获得完整能力。 +

+

+ 侧栏:仅显示当前文档大纲。Web 版不提供文件夹浏览与打开历史,请用工具栏「打开」或 + Ctrl+O 选择文件。 +

+

+ 图片与链接:本地 file:// 图片受浏览器安全策略限制,可能无法显示;可改用 + 网络图片地址,或将图片与文档放在同一目录后重新打开。文内相对链接在已打开文件范围内解析。 +

+

+ 快捷键:Ctrl+O 打开 · Ctrl+S 保存 · Ctrl+Shift+S 另存为 · Ctrl+T 新标签 · + Ctrl+W 关闭标签 · Ctrl+E 切换视图 · Ctrl+Tab / Ctrl+Shift+Tab 切换标签。 +

+

+ 作者:黄志军 · 支持与协助(wx):liaofan199404 +

+
+
+ +
+
+
+ ) : null} + + {desktopDownloadOpen ? ( +
setDesktopDownloadOpen(false)} + > +
e.stopPropagation()} + > +

+ 桌面版下载说明 +

+
+

+ 为什么推荐桌面版? + Web 版在 HTTP 站点上无法直接写回您电脑上的原文件,保存时往往只能触发下载;文件夹浏览、稳定保存等能力也受浏览器限制。桌面版(Electron)在 + Windows 本地运行,可像普通编辑器一样打开、保存 Markdown,体验更接近完整版 MyReader。 +

+

+ 如何安装? +

+
    +
  1. 点击下方链接下载 ZIP 压缩包;
  2. +
  3. 解压到任意目录(路径尽量不要含特殊字符);
  4. +
  5. 进入解压目录,运行其中的可执行程序(一般为 .exe);
  6. +
  7. 首次使用可通过菜单「打开」选择本地 .md 文件进行阅读与编辑。
  8. +
+

+ 下载地址: +
+ + {DESKTOP_DOWNLOAD_URL} + +

+

+ 若链接无法打开,请检查网络或联系站点管理员。桌面版仅支持 Windows;Mac / Linux 请继续使用 Web 版。 +

+
+
+ + 前往下载 + + +
+
+
+ ) : null} + +
+ {tabs.map((t) => ( +
+ + +
+ ))} +
+ +
+ {saveToast ? ( +
+ {saveToast} +
+ ) : null} + + + + + + + + +
+ + + +
+
+ +
+ {!sidebarHidden ? ( + <> + +
+ + ) : ( + + )} + +
+ {(viewMode === 'edit' || viewMode === 'hybrid') && activeTab ? ( + + ) : null} + {viewMode === 'read' ? ( + activeTab ? ( +
+
+ {renderPreviewArticle(readPreviewArticleRef)} +
+
+
+ ) : ( +
+ 请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。 +
+ ) + ) : null} + + {viewMode === 'edit' ? ( + activeTab ? ( + + ) : ( +
+ 请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。 +
+ ) + ) : null} + + {viewMode === 'hybrid' ? ( + activeTab ? ( +
+
+ +
+
+
+
+ {hybridLinePulse ? ( +
+ ) : null} + {renderPreviewArticle(hybridPreviewArticleRef)} +
+
+
+ ) : ( +
+ 请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。 +
+ ) + ) : null} +
+
+
+ ) +} diff --git a/src/EditorPane.tsx b/src/EditorPane.tsx new file mode 100644 index 0000000..f369391 --- /dev/null +++ b/src/EditorPane.tsx @@ -0,0 +1,370 @@ +import * as React from 'react' +import { EditorSelection } from '@codemirror/state' +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { markdown } from '@codemirror/lang-markdown' +import { EditorState } from '@codemirror/state' +import { + EditorView, + highlightActiveLine, + keymap, + lineNumbers, + placeholder +} from '@codemirror/view' + +export type MarkdownEditorHandle = { + applyAction: (id: string) => void | Promise +} + +type Props = { + mountKey: string + value: string + onChange: (next: string) => void + gotoLine?: { line: number; seq: number } | null + showLineNumbers?: boolean + /** In Electron, `window.prompt` is unreliable; use an in-app prompt from the host. */ + requestText?: (title: string, defaultValue: string) => Promise + onViewReady?: (view: EditorView | null) => void + /** 分栏:根据光标所在行在编辑器可滚动内容中的垂直比例,对齐阅读区提示条。 */ + onHybridCursorLine?: (line: number) => void + requestImageDetails?: () => Promise<{ url: string; alt: string } | null> + requestColor?: () => Promise +} + +function getMainSelection(view: EditorView): { from: number; to: number } { + const r = view.state.selection.main + return { from: r.from, to: r.to } +} + +function wrap(view: EditorView, left: string, right: string): void { + const { from, to } = getMainSelection(view) + const sel = view.state.sliceDoc(from, to) + view.dispatch({ + changes: { from, to, insert: left + sel + right }, + selection: EditorSelection.range(from + left.length, from + left.length + sel.length) + }) + view.focus() +} + +function insertAtCursor(view: EditorView, text: string): void { + const { from, to } = getMainSelection(view) + view.dispatch({ + changes: { from, to, insert: text }, + selection: EditorSelection.cursor(from + text.length) + }) + view.focus() +} + +function insertLines(view: EditorView, text: string): void { + insertAtCursor(view, text) +} + +function linePrefixOnMainLine(view: EditorView, prefix: string): void { + const pos = view.state.selection.main.head + const line = view.state.doc.lineAt(pos) + const lineText = line.text + const hashes = /^#+\s/.exec(lineText)?.[0] ?? '' + let rest = lineText + if (hashes) rest = lineText.slice(hashes.length) + const newLine = prefix + rest + view.dispatch({ + changes: { from: line.from, to: line.to, insert: newLine }, + selection: EditorSelection.cursor(line.from + newLine.length) + }) + view.focus() +} + +function escapeMarkdownLinkFragment(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]') +} + +function sanitizeInlineColor(c: string): string { + const t = c.trim() + if (/^#[0-9a-fA-F]{3}$/.test(t)) { + const r = t[1]! + const g = t[2]! + const b = t[3]! + return `#${r}${r}${g}${g}${b}${b}` + } + if (/^#[0-9a-fA-F]{6}$/.test(t) || /^#[0-9a-fA-F]{8}$/.test(t)) return t.toLowerCase() + if (/^rgba?\(\s*[\d.\s%,]+\s*\)$/.test(t)) return t + if (/^hsla?\(\s*[\d.\s%,deg]+\s*\)$/.test(t)) return t + if (/^[\w-]{1,40}$/.test(t)) return t + return '#333333' +} + +function sanitizeLinkUrl(url: string): string { + const t = url.trim() || 'https://' + if (/^file:/i.test(t)) return `<${t.replace(/[<>]/g, '')}>` + return t.replace(/\)/g, '%29') +} + +type ActionDeps = { + requestText: (title: string, def: string) => Promise + requestImageDetails?: () => Promise<{ url: string; alt: string } | null> + requestColor?: () => Promise +} + +async function applyMarkdownAction(view: EditorView, id: string, deps: ActionDeps): Promise { + const { requestText, requestImageDetails, requestColor } = deps + try { + switch (id) { + case 'bold': + wrap(view, '**', '**') + break + case 'italic': + wrap(view, '*', '*') + break + case 'strike': + wrap(view, '~~', '~~') + break + case 'code': + wrap(view, '`', '`') + break + case 'codeBlock': + insertLines(view, '\n```\n\n```\n') + break + case 'link': { + const url = await requestText('链接地址', 'https://') + if (url === null) return + const { from, to } = getMainSelection(view) + const rawLabel = view.state.sliceDoc(from, to) || '链接文字' + const label = escapeMarkdownLinkFragment(rawLabel) + const insert = `[${label}](${sanitizeLinkUrl(url)})` + view.dispatch({ + changes: { from, to, insert }, + selection: EditorSelection.cursor(from + insert.length) + }) + view.focus() + break + } + case 'image': { + if (requestImageDetails) { + const det = await requestImageDetails() + if (!det?.url?.trim()) return + const alt = (det.alt ?? '图片').trim() || '图片' + insertAtCursor( + view, + `![${escapeMarkdownLinkFragment(alt)}](${sanitizeLinkUrl(det.url.trim())})` + ) + break + } + const url = await requestText('图片地址', 'https://') + if (url === null) return + const rawAlt = await requestText('替代文字', '图片') + if (rawAlt === null) return + const alt = escapeMarkdownLinkFragment(rawAlt || '图片') + insertAtCursor(view, `![${alt}](${sanitizeLinkUrl(url)})`) + break + } + case 'h1': + linePrefixOnMainLine(view, '# ') + break + case 'h2': + linePrefixOnMainLine(view, '## ') + break + case 'h3': + linePrefixOnMainLine(view, '### ') + break + case 'h4': + linePrefixOnMainLine(view, '#### ') + break + case 'h5': + linePrefixOnMainLine(view, '##### ') + break + case 'h6': + linePrefixOnMainLine(view, '###### ') + break + case 'ul': + linePrefixOnMainLine(view, '- ') + break + case 'ol': + linePrefixOnMainLine(view, '1. ') + break + case 'task': + linePrefixOnMainLine(view, '- [ ] ') + break + case 'quote': + linePrefixOnMainLine(view, '> ') + break + case 'hr': + insertLines(view, '\n---\n') + break + case 'table': + insertLines( + view, + '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n' + ) + break + case 'mark': + wrap(view, '', '') + break + case 'color': { + if (requestColor) { + const c = await requestColor() + if (c === null || !c.trim()) return + const color = sanitizeInlineColor(c) + wrap(view, ``, '') + break + } + const c = await requestText('颜色(如 #e11 或 red)', '#dc2626') + if (c === null || !c.trim()) return + const color = sanitizeInlineColor(c) + wrap(view, ``, '') + break + } + case 'sup': + wrap(view, '', '') + break + case 'sub': + wrap(view, '', '') + break + case 'kbd': + wrap(view, '', '') + break + case 'details': + insertLines(view, '\n
\n标题\n内容\n
\n') + break + case 'footnote': + insertLines(view, '[^1]\n\n[^1]: 脚注内容\n') + break + default: + break + } + } catch (e) { + console.error('[MyReader] toolbar action failed', id, e) + } +} + +export const EditorPane = React.forwardRef( + function EditorPane( + { + mountKey, + value, + onChange, + gotoLine, + showLineNumbers = true, + requestText, + onViewReady, + onHybridCursorLine, + requestImageDetails, + requestColor + }: Props, + ref + ): React.ReactElement { + const hostRef = React.useRef(null) + const viewRef = React.useRef(null) + const onChangeRef = React.useRef(onChange) + onChangeRef.current = onChange + + const requestTextRef = React.useRef(requestText) + requestTextRef.current = requestText + + const onViewReadyRef = React.useRef(onViewReady) + onViewReadyRef.current = onViewReady + + const onHybridCursorLineRef = React.useRef(onHybridCursorLine) + onHybridCursorLineRef.current = onHybridCursorLine + + const requestImageDetailsRef = React.useRef(requestImageDetails) + requestImageDetailsRef.current = requestImageDetails + + const requestColorRef = React.useRef(requestColor) + requestColorRef.current = requestColor + + React.useImperativeHandle( + ref, + () => ({ + applyAction: async (id: string) => { + const view = viewRef.current + if (!view) return + const rt = + requestTextRef.current ?? + ((title: string, def: string) => + Promise.resolve(window.prompt(title, def))) + await applyMarkdownAction(view, id, { + requestText: rt, + requestImageDetails: requestImageDetailsRef.current, + requestColor: requestColorRef.current + }) + } + }), + [] + ) + + React.useEffect(() => { + const host = hostRef.current + if (!host) return + host.replaceChildren() + + const view = new EditorView({ + parent: host, + state: EditorState.create({ + doc: value, + extensions: [ + history(), + markdown(), + ...(showLineNumbers ? [lineNumbers()] : []), + highlightActiveLine(), + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + placeholder('在此编辑 Markdown…'), + EditorView.lineWrapping, + EditorView.updateListener.of((u) => { + if (u.docChanged) { + onChangeRef.current(u.state.doc.toString()) + } + }), + EditorView.updateListener.of((u) => { + if (!onHybridCursorLineRef.current) return + if (!u.selectionSet && !u.focusChanged && !u.docChanged) return + const pos = u.state.selection.main.head + const line = u.state.doc.lineAt(pos) + onHybridCursorLineRef.current(line.number - 1) + }), + EditorView.theme({ + '&': { + height: '100%', + fontSize: '14px', + fontFamily: 'var(--font-ui, system-ui, sans-serif)' + }, + '.cm-scroller': { fontFamily: 'inherit' }, + '.cm-content': { paddingBlock: '12px' } + }) + ] + }) + }) + viewRef.current = view + onViewReadyRef.current?.(view) + return () => { + onViewReadyRef.current?.(null) + view.destroy() + viewRef.current = null + host.replaceChildren() + } + }, [mountKey, showLineNumbers]) + + React.useEffect(() => { + const view = viewRef.current + if (!view) return + const cur = view.state.doc.toString() + if (cur !== value) { + view.dispatch({ + changes: { from: 0, to: cur.length, insert: value }, + selection: { anchor: 0 }, + scrollIntoView: true + }) + } + }, [value, mountKey]) + + React.useEffect(() => { + if (!gotoLine) return + const view = viewRef.current + if (!view) return + const lineNo = gotoLine.line + 1 + if (lineNo < 1 || lineNo > view.state.doc.lines) return + const line = view.state.doc.line(lineNo) + view.dispatch({ selection: { anchor: line.from }, scrollIntoView: true }) + }, [gotoLine]) + + return
+ } +) diff --git a/src/MarkdownFormatToolbar.tsx b/src/MarkdownFormatToolbar.tsx new file mode 100644 index 0000000..7378e66 --- /dev/null +++ b/src/MarkdownFormatToolbar.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import type { MarkdownEditorHandle } from './EditorPane' + +const BUTTONS: { id: string; label: string }[] = [ + { id: 'h1', label: 'H1' }, + { id: 'h2', label: 'H2' }, + { id: 'h3', label: 'H3' }, + { id: 'h4', label: 'H4' }, + { id: 'h5', label: 'H5' }, + { id: 'h6', label: 'H6' }, + { id: 'bold', label: '加粗' }, + { id: 'italic', label: '斜体' }, + { id: 'strike', label: '删除线' }, + { id: 'code', label: '行内代码' }, + { id: 'codeBlock', label: '代码块' }, + { id: 'link', label: '链接' }, + { id: 'image', label: '图片' }, + { id: 'ul', label: '无序列表' }, + { id: 'ol', label: '有序列表' }, + { id: 'task', label: '任务' }, + { id: 'quote', label: '引用' }, + { id: 'hr', label: '分隔线' }, + { id: 'table', label: '表格' }, + { id: 'mark', label: '高亮' }, + { id: 'color', label: '颜色' }, + { id: 'sup', label: '上标' }, + { id: 'sub', label: '下标' }, + { id: 'kbd', label: '按键' }, + { id: 'details', label: '折叠' }, + { id: 'footnote', label: '脚注' } +] + +type Props = { + editorRef: React.RefObject +} + +export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement { + return ( +
+
+ {BUTTONS.map((b) => ( + + ))} +
+
+ ) +} diff --git a/src/OutlinePanel.tsx b/src/OutlinePanel.tsx new file mode 100644 index 0000000..cc98e50 --- /dev/null +++ b/src/OutlinePanel.tsx @@ -0,0 +1,110 @@ +import * as React from 'react' +import type { OutlineItem } from './markdown' +import { buildOutlineTree, collectBranchIds, type OutlineTreeNode } from './outlineTree' + +type Props = { + items: OutlineItem[] + onSelect: (item: OutlineItem) => void +} + +export function OutlinePanel({ items, onSelect }: Props): React.ReactElement { + const tree = React.useMemo(() => buildOutlineTree(items), [items]) + const branchIds = React.useMemo(() => collectBranchIds(tree), [tree]) + const [collapsed, setCollapsed] = React.useState>(() => new Set()) + + React.useEffect(() => { + setCollapsed(new Set()) + }, [items]) + + const toggleBranch = React.useCallback((id: string): void => { + setCollapsed((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const expandAll = React.useCallback((): void => { + setCollapsed(new Set()) + }, []) + + const collapseAll = React.useCallback((): void => { + setCollapsed(new Set(branchIds)) + }, [branchIds]) + + const renderNode = (node: OutlineTreeNode): React.ReactElement => { + const hasChildren = node.children.length > 0 + const isCollapsed = collapsed.has(node.id) + + return ( +
  • +
    + {hasChildren ? ( + + ) : ( + + )} + +
    + {hasChildren && !isCollapsed ? ( +
      {node.children.map(renderNode)}
    + ) : null} +
  • + ) + } + + if (items.length === 0) { + return

    当前文档没有标题

    + } + + const collapsedCount = collapsed.size + const branchCount = branchIds.length + + return ( +
    +
    + + 大纲 + {branchCount > 0 ? ( + + {' '} + · {branchCount - collapsedCount}/{branchCount} 已展开 + + ) : null} + + + +
    +
      {tree.map(renderNode)}
    +
    + ) +} diff --git a/src/PreviewArticle.tsx b/src/PreviewArticle.tsx new file mode 100644 index 0000000..693c550 --- /dev/null +++ b/src/PreviewArticle.tsx @@ -0,0 +1,258 @@ +import * as React from 'react' +import mermaid from 'mermaid' +import { webVfs } from './platform/webVfs' + +let mermaidInited = false +let mermaidSeq = 0 + +function ensureMermaid(): void { + if (mermaidInited) return + mermaid.initialize({ + startOnLoad: false, + theme: 'neutral', + securityLevel: 'loose', + fontFamily: 'inherit', + flowchart: { useMaxWidth: true, htmlLabels: true }, + sequence: { useMaxWidth: true, wrap: true }, + er: { useMaxWidth: true }, + gantt: { useMaxWidth: true } + }) + mermaidInited = true +} + +function mermaidSourceFromWrap(wrap: HTMLElement): string { + const enc = wrap.getAttribute('data-mermaid-src') + if (enc) { + try { + return decodeURIComponent(enc) + } catch { + /* fall through */ + } + } + const pre = wrap.querySelector('pre.mermaid') + return pre?.textContent ?? '' +} + +async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promise { + const wraps = root.querySelectorAll('.md-diagram-wrap') + if (wraps.length === 0) return + + ensureMermaid() + + for (const wrap of wraps) { + if (isStale()) return + + const source = mermaidSourceFromWrap(wrap).trim() + if (!source) continue + + delete wrap.dataset.mermaidRendered + + let host = wrap.querySelector('.mermaid-render-host') + const pre = wrap.querySelector('pre.mermaid') + if (pre) { + host = document.createElement('div') + host.className = 'mermaid-render-host' + pre.replaceWith(host) + } else if (!host) { + host = document.createElement('div') + host.className = 'mermaid-render-host' + wrap.appendChild(host) + } + + host.innerHTML = '
    ' + host.removeAttribute('data-mermaid-error') + + const id = `mr-mmd-${++mermaidSeq}` + try { + const { svg, bindFunctions } = await mermaid.render(id, source) + if (isStale()) return + if (!root.isConnected || !wrap.isConnected) return + host.innerHTML = svg + bindFunctions?.(host) + wrap.dataset.mermaidRendered = '1' + } catch (err) { + if (isStale()) return + const msg = err instanceof Error ? err.message : String(err) + host.setAttribute('data-mermaid-error', msg) + host.innerHTML = `
    ${escapeHtml(msg)}\n\n${escapeHtml(source.slice(0, 2000))}
    ` + console.warn("[Huangzhijun's Reader] mermaid render failed", err) + } + } +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function isMarkdownPath(p: string): boolean { + return /\.(md|markdown|mdown|mkd)$/i.test(p) +} + +function linkHref(anchor: HTMLAnchorElement): string | null { + return anchor.getAttribute('href') ?? anchor.getAttribute('data-md-href') +} + +type Props = { + html: string + articleRef?: React.Ref + baseFilePath?: string | null + resolveRelativePath?: (baseFilePath: string, href: string) => Promise + onOpenMarkdown?: (absolutePath: string, anchorId?: string) => void | Promise + onOpenExternal?: (url: string) => void | Promise +} + +export function PreviewArticle({ + html, + articleRef, + baseFilePath, + resolveRelativePath, + onOpenMarkdown, + onOpenExternal +}: Props): React.ReactElement { + const innerRef = React.useRef(null) + const renderGenRef = React.useRef(0) + const baseFilePathRef = React.useRef(baseFilePath) + baseFilePathRef.current = baseFilePath + const resolveRef = React.useRef(resolveRelativePath) + resolveRef.current = resolveRelativePath + const openMdRef = React.useRef(onOpenMarkdown) + openMdRef.current = onOpenMarkdown + const openExtRef = React.useRef(onOpenExternal) + openExtRef.current = onOpenExternal + + const setRef = React.useCallback( + (el: HTMLElement | null) => { + innerRef.current = el + if (typeof articleRef === 'function') articleRef(el) + else if (articleRef && typeof articleRef === 'object' && 'current' in articleRef) { + ;(articleRef as React.MutableRefObject).current = el + } + }, + [articleRef] + ) + + const scrollToAnchor = React.useCallback((root: HTMLElement, anchorId: string): void => { + const id = decodeURIComponent(anchorId) + if (!id) return + let el: Element | null = null + try { + el = root.querySelector(`#${CSS.escape(id)}`) + } catch { + el = root.querySelector(`#${id}`) + } + if (!el) el = document.getElementById(id) + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, []) + + const handleClickCapture = React.useCallback((e: React.MouseEvent) => { + if (e.defaultPrevented || e.button !== 0) return + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + const root = innerRef.current + if (!root) return + + const anchor = (e.target as Element | null)?.closest('a') + if (!anchor || !root.contains(anchor)) return + + const href = linkHref(anchor) + if (!href) return + + e.preventDefault() + e.stopPropagation() + + void (async () => { + if (href.startsWith('#')) { + scrollToAnchor(root, href.slice(1)) + return + } + + const hashIdx = href.indexOf('#') + const pathPart = (hashIdx >= 0 ? href.slice(0, hashIdx) : href).trim() + const anchorId = hashIdx >= 0 ? href.slice(hashIdx + 1) : '' + if (!pathPart) { + if (anchorId) scrollToAnchor(root, anchorId) + return + } + + if (/^https?:\/\//i.test(pathPart) || /^mailto:/i.test(pathPart) || /^tel:/i.test(pathPart)) { + await openExtRef.current?.(pathPart) + return + } + + const base = baseFilePathRef.current + if (!base) { + window.alert('请先保存当前文件,再打开文内相对链接。') + return + } + + const resolve = resolveRef.current + if (!resolve) return + + const resolved = await resolve(base, pathPart) + if (!resolved) return + + if (/^https?:\/\//i.test(resolved) || /^mailto:/i.test(resolved) || /^tel:/i.test(resolved)) { + await openExtRef.current?.(resolved) + return + } + + if (isMarkdownPath(resolved)) { + await openMdRef.current?.(resolved, anchorId || undefined) + return + } + + window.alert(`暂不支持打开该类型链接:${resolved}`) + })() + }, [scrollToAnchor]) + + React.useLayoutEffect(() => { + const root = innerRef.current + if (!root) return + + const gen = ++renderGenRef.current + root.innerHTML = html + + const isStale = (): boolean => gen !== renderGenRef.current + + void (async () => { + for (const img of root.querySelectorAll('img')) { + if (isStale()) return + const disk = img.getAttribute('data-disk-path') + const src = img.getAttribute('src') + if (!disk && (!src || /^(blob:|data:|https?:)/i.test(src))) continue + const blob = await webVfs.resolveImageToBlobUrl( + disk || src || '', + baseFilePath ?? null + ) + if (isStale()) return + if (blob) { + img.setAttribute('src', blob) + img.removeAttribute('data-img-error') + } else if (disk) { + img.setAttribute('data-img-error', '1') + img.setAttribute( + 'title', + `无法加载图片:${disk}\n请将图片与 Markdown 放在同一目录后通过「打开」重新选择,或使用网络图片地址。` + ) + img.alt = img.alt || '图片未加载' + } + } + })() + + void renderMermaidIn(root, isStale).catch((err) => { + console.warn("[Huangzhijun's Reader] mermaid batch failed", err) + }) + + return () => { + renderGenRef.current += 1 + } + }, [html, baseFilePath]) + + return ( +
    + ) +} diff --git a/src/appearance-styles.css b/src/appearance-styles.css new file mode 100644 index 0000000..1f56ca8 --- /dev/null +++ b/src/appearance-styles.css @@ -0,0 +1,493 @@ +/* 背景(12):颜色变量,挂到 document.documentElement data-bg */ +:root[data-bg='0'] { + --app-bg: #f3f4f6; + --panel-bg: #e5e7eb; + --text: #111827; + --muted: #6b7280; + --border: #d1d5db; + --accent: #2563eb; + --btn-bg: #ffffff; + --hover: rgba(0, 0, 0, 0.06); + --preview-bg: #ffffff; + --preview-text: #111827; + --preview-shadow: 0 8px 30px rgba(15, 23, 42, 0.08); + --code-bg: #f9fafb; +} +:root[data-bg='1'] { + --app-bg: #f4ecd8; + --panel-bg: #e8dfc8; + --text: #3e3428; + --muted: #7a6a58; + --border: #d2c7b2; + --accent: #8b4513; + --btn-bg: #fffaf0; + --hover: rgba(62, 52, 40, 0.08); + --preview-bg: #fffaf0; + --preview-text: #3e3428; + --preview-shadow: 0 8px 24px rgba(62, 52, 40, 0.12); + --code-bg: #f0e6d6; +} +:root[data-bg='2'] { + --app-bg: #0f172a; + --panel-bg: #111c33; + --text: #e5e7eb; + --muted: #94a3b8; + --border: #1e293b; + --accent: #38bdf8; + --btn-bg: #1e293b; + --hover: rgba(148, 163, 184, 0.12); + --preview-bg: #111827; + --preview-text: #e5e7eb; + --preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + --code-bg: #0b1224; +} +:root[data-bg='3'] { + --app-bg: #102216; + --panel-bg: #143220; + --text: #e7f7ec; + --muted: #8fb39a; + --border: #1f3b2a; + --accent: #4ade80; + --btn-bg: #163527; + --hover: rgba(74, 222, 128, 0.12); + --preview-bg: #132a1f; + --preview-text: #e7f7ec; + --preview-shadow: 0 10px 36px rgba(0, 0, 0, 0.45); + --code-bg: #0d1f16; +} +:root[data-bg='4'] { + --app-bg: linear-gradient(160deg, #e0f2fe 0%, #eef2ff 45%, #fdf4ff 100%); + --panel-bg: rgba(255, 255, 255, 0.72); + --text: #0f172a; + --muted: #475569; + --border: #cbd5f5; + --accent: #4f46e5; + --btn-bg: rgba(255, 255, 255, 0.9); + --hover: rgba(79, 70, 229, 0.1); + --preview-bg: rgba(255, 255, 255, 0.92); + --preview-text: #0f172a; + --preview-shadow: 0 12px 40px rgba(15, 23, 42, 0.12); + --code-bg: #f8fafc; +} +:root[data-bg='5'] { + --app-bg: #fafafa; + --panel-bg: #ffffff; + --text: #111111; + --muted: #737373; + --border: #e5e5e5; + --accent: #db2777; + --btn-bg: #ffffff; + --hover: rgba(219, 39, 119, 0.08); + --preview-bg: #ffffff; + --preview-text: #111111; + --preview-shadow: 0 10px 30px rgba(15, 23, 42, 0.06); + --code-bg: #f5f5f5; +} +:root[data-bg='6'] { + --app-bg: #121212; + --panel-bg: #1a1a1a; + --text: #fafafa; + --muted: #a3a3a3; + --border: #262626; + --accent: #f97316; + --btn-bg: #171717; + --hover: rgba(249, 115, 22, 0.15); + --preview-bg: #0a0a0a; + --preview-text: #fafafa; + --preview-shadow: 0 0 0 1px #262626; + --code-bg: #000000; +} +:root[data-bg='7'] { + --app-bg: #f5f3ff; + --panel-bg: #ede9fe; + --text: #312e81; + --muted: #6d28d9; + --border: #ddd6fe; + --accent: #7c3aed; + --btn-bg: #ffffff; + --hover: rgba(124, 58, 237, 0.1); + --preview-bg: #ffffff; + --preview-text: #312e81; + --preview-shadow: 0 10px 32px rgba(49, 46, 129, 0.12); + --code-bg: #f5f3ff; +} +:root[data-bg='8'] { + --app-bg: #1c1917; + --panel-bg: #292524; + --text: #fafaf9; + --muted: #a8a29e; + --border: #44403c; + --accent: #fbbf24; + --btn-bg: #292524; + --hover: rgba(251, 191, 36, 0.12); + --preview-bg: #1c1917; + --preview-text: #fafaf9; + --preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); + --code-bg: #0c0a09; +} +:root[data-bg='9'] { + --app-bg: #1a1033; + --panel-bg: #221547; + --text: #f3e8ff; + --muted: #c4b5fd; + --border: #3b2a6b; + --accent: #a78bfa; + --btn-bg: #2a185f; + --hover: rgba(167, 139, 250, 0.15); + --preview-bg: #120c24; + --preview-text: #f3e8ff; + --preview-shadow: 0 14px 48px rgba(0, 0, 0, 0.55); + --code-bg: #0f0820; +} +:root[data-bg='10'] { + --app-bg: #e8f4fc; + --panel-bg: #d7eaf8; + --text: #0c4a6e; + --muted: #0369a1; + --border: #bae6fd; + --accent: #0284c7; + --btn-bg: #f0f9ff; + --hover: rgba(2, 132, 199, 0.12); + --preview-bg: #ffffff; + --preview-text: #0c4a6e; + --preview-shadow: 0 10px 28px rgba(12, 74, 110, 0.1); + --code-bg: #e0f2fe; +} +:root[data-bg='11'] { + --app-bg: #fdf2f4; + --panel-bg: #fce7eb; + --text: #881337; + --muted: #9f1239; + --border: #fbcfe8; + --accent: #db2777; + --btn-bg: #fff1f2; + --hover: rgba(219, 39, 119, 0.1); + --preview-bg: #ffffff; + --preview-text: #881337; + --preview-shadow: 0 10px 28px rgba(136, 19, 55, 0.08); + --code-bg: #ffe4e6; +} +:root[data-bg='12'] { + --app-bg: #18181b; + --panel-bg: #27272a; + --text: #fafafa; + --muted: #a1a1aa; + --border: #3f3f46; + --accent: #22d3ee; + --btn-bg: #27272a; + --hover: rgba(34, 211, 238, 0.12); + --preview-bg: #18181b; + --preview-text: #fafafa; + --preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + --code-bg: #09090b; +} +:root[data-bg='13'] { + --app-bg: #ecfeff; + --panel-bg: #cffafe; + --text: #0e7490; + --muted: #0891b2; + --border: #a5f3fc; + --accent: #0d9488; + --btn-bg: #f0fdfa; + --hover: rgba(13, 148, 136, 0.12); + --preview-bg: #ffffff; + --preview-text: #134e4a; + --preview-shadow: 0 10px 28px rgba(8, 145, 178, 0.1); + --code-bg: #ccfbf1; +} +:root[data-bg='14'] { + --app-bg: #faf5f0; + --panel-bg: #ede4d8; + --text: #422006; + --muted: #92400e; + --border: #d6c4b0; + --accent: #b45309; + --btn-bg: #fffbeb; + --hover: rgba(180, 83, 9, 0.1); + --preview-bg: #fffbeb; + --preview-text: #422006; + --preview-shadow: 0 10px 28px rgba(66, 32, 6, 0.08); + --code-bg: #fef3c7; +} +:root[data-bg='15'] { + --app-bg: #f0fdfa; + --panel-bg: #ccfbf1; + --text: #115e59; + --muted: #0f766e; + --border: #99f6e4; + --accent: #0d9488; + --btn-bg: #ecfdf5; + --hover: rgba(13, 148, 136, 0.12); + --preview-bg: #ffffff; + --preview-text: #134e4a; + --preview-shadow: 0 10px 28px rgba(17, 94, 89, 0.08); + --code-bg: #d1fae5; +} +:root[data-bg='16'] { + --app-bg: #f5f3ff; + --panel-bg: #ede9fe; + --text: #4c1d95; + --muted: #6d28d9; + --border: #ddd6fe; + --accent: #7c3aed; + --btn-bg: #faf5ff; + --hover: rgba(124, 58, 237, 0.1); + --preview-bg: #ffffff; + --preview-text: #4c1d95; + --preview-shadow: 0 10px 28px rgba(76, 29, 149, 0.08); + --code-bg: #ede9fe; +} +:root[data-bg='17'] { + --app-bg: #faf8f5; + --panel-bg: #f3efe8; + --text: #44403c; + --muted: #78716c; + --border: #e7e5e4; + --accent: #d97706; + --btn-bg: #ffffff; + --hover: rgba(217, 119, 6, 0.1); + --preview-bg: #ffffff; + --preview-text: #44403c; + --preview-shadow: 0 10px 28px rgba(68, 64, 60, 0.08); + --code-bg: #f5f5f4; +} +:root[data-bg='18'] { + --app-bg: #1c1917; + --panel-bg: #292524; + --text: #fafaf9; + --muted: #a8a29e; + --border: #44403c; + --accent: #eab308; + --btn-bg: #292524; + --hover: rgba(234, 179, 8, 0.12); + --preview-bg: #1c1917; + --preview-text: #fafaf9; + --preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + --code-bg: #0c0a09; +} +:root[data-bg='19'] { + --app-bg: #fdf4f8; + --panel-bg: #fce7f3; + --text: #831843; + --muted: #be185d; + --border: #fbcfe8; + --accent: #db2777; + --btn-bg: #fff1f2; + --hover: rgba(219, 39, 119, 0.1); + --preview-bg: #ffffff; + --preview-text: #831843; + --preview-shadow: 0 10px 28px rgba(131, 24, 67, 0.08); + --code-bg: #ffe4e6; +} +:root[data-bg='20'] { + --app-bg: #f1f5f9; + --panel-bg: #e2e8f0; + --text: #1e293b; + --muted: #64748b; + --border: #cbd5e1; + --accent: #475569; + --btn-bg: #ffffff; + --hover: rgba(71, 85, 105, 0.12); + --preview-bg: #ffffff; + --preview-text: #1e293b; + --preview-shadow: 0 10px 28px rgba(30, 41, 59, 0.1); + --code-bg: #f8fafc; +} +:root[data-bg='21'] { + --app-bg: #ecfdf5; + --panel-bg: #d1fae5; + --text: #14532d; + --muted: #166534; + --border: #bbf7d0; + --accent: #15803d; + --btn-bg: #f0fdf4; + --hover: rgba(21, 128, 61, 0.12); + --preview-bg: #ffffff; + --preview-text: #14532d; + --preview-shadow: 0 10px 28px rgba(20, 83, 45, 0.08); + --code-bg: #dcfce7; +} + +/* Kindle 风格:暖灰纸面 + 轻微颗粒感(纯 CSS) */ +:root[data-bg='22'] { + --app-bg: #e8e2d8; + --panel-bg: rgba(245, 241, 234, 0.94); + --text: #2d2a26; + --muted: #6b6560; + --border: #c9c2b8; + --accent: #c2410c; + --btn-bg: #faf6ef; + --hover: rgba(194, 65, 12, 0.08); + --preview-bg: #f7f3eb; + --preview-text: #1f1c18; + --preview-shadow: 0 10px 28px rgba(45, 42, 38, 0.1); + --code-bg: #ede8df; +} + +/* 字体:仅字体栈 */ +:root[data-font='0'] { + --font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-editor: ui-monospace, Menlo, Consolas, monospace; + --font-preview: Georgia, 'Times New Roman', 'Noto Serif SC', serif; +} +:root[data-font='1'] { + --font-ui: Georgia, 'Times New Roman', serif; + --font-editor: Consolas, 'Courier New', monospace; + --font-preview: 'Palatino Linotype', Palatino, 'Noto Serif SC', serif; +} +:root[data-font='2'] { + --font-ui: system-ui, sans-serif; + --font-editor: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + --font-preview: 'Source Serif 4', Cambria, Georgia, serif; +} +:root[data-font='3'] { + --font-ui: 'Microsoft YaHei', 'PingFang SC', sans-serif; + --font-editor: 'Sarasa Mono SC', Consolas, monospace; + --font-preview: 'Songti SC', SimSun, 'Noto Serif SC', serif; +} +:root[data-font='4'] { + --font-ui: 'IBM Plex Sans', system-ui, sans-serif; + --font-editor: 'IBM Plex Mono', Consolas, monospace; + --font-preview: 'IBM Plex Serif', Georgia, serif; +} +:root[data-font='5'] { + --font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-editor: Menlo, Monaco, Consolas, monospace; + --font-preview: 'Helvetica Neue', Arial, sans-serif; +} +:root[data-font='6'] { + --font-ui: Verdana, Geneva, sans-serif; + --font-editor: 'Lucida Console', monospace; + --font-preview: Georgia, Cambria, serif; +} +:root[data-font='7'] { + --font-ui: 'Trebuchet MS', sans-serif; + --font-editor: 'Courier New', Courier, monospace; + --font-preview: 'Book Antiqua', Palatino, serif; +} +:root[data-font='8'] { + --font-ui: 'Segoe UI', system-ui, sans-serif; + --font-editor: 'Cascadia Code', 'Cascadia Mono', Consolas, monospace; + --font-preview: Constantia, 'Times New Roman', serif; +} +:root[data-font='9'] { + --font-ui: 'Segoe UI Variable', 'Segoe UI', sans-serif; + --font-editor: 'Cascadia Mono', Consolas, monospace; + --font-preview: 'Sitka Text', Georgia, serif; +} +:root[data-font='10'] { + --font-ui: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif; + --font-editor: 'Noto Sans Mono CJK SC', 'Sarasa Mono SC', Consolas, monospace; + --font-preview: 'Noto Serif SC', 'Source Han Serif SC', SimSun, serif; +} +:root[data-font='11'] { + --font-ui: 'Hiragino Sans GB', 'Microsoft YaHei', system-ui, sans-serif; + --font-editor: ui-monospace, monospace; + --font-preview: 'STKaiti', KaiTi, 'Noto Serif SC', serif; +} +:root[data-font='12'] { + --font-ui: 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; + --font-editor: 'Menlo', 'Monaco', Consolas, monospace; + --font-preview: 'Songti SC', SimSun, 'Noto Serif SC', serif; +} +:root[data-font='13'] { + --font-ui: 'Hiragino Sans GB', 'PingFang SC', sans-serif; + --font-editor: 'SF Mono', Menlo, Consolas, monospace; + --font-preview: 'Hiragino Mincho ProN', 'Songti SC', serif; +} +:root[data-font='14'] { + --font-ui: 'LXGW WenKai', 'KaiTi', 'Microsoft YaHei', sans-serif; + --font-editor: 'JetBrains Mono', Consolas, monospace; + --font-preview: 'LXGW WenKai', 'KaiTi', 'Noto Serif SC', serif; +} +:root[data-font='15'] { + --font-ui: 'HarmonyOS Sans', 'Microsoft YaHei', system-ui, sans-serif; + --font-editor: 'HarmonyOS Sans Mono', Consolas, monospace; + --font-preview: 'HarmonyOS Serif', 'Noto Serif SC', serif; +} +:root[data-font='16'] { + --font-ui: 'OPPO Sans', 'Microsoft YaHei', system-ui, sans-serif; + --font-editor: 'OPPO Sans Mono', Consolas, monospace; + --font-preview: 'Source Han Serif SC', SimSun, serif; +} +:root[data-font='17'] { + --font-ui: 'MiSans', 'Microsoft YaHei', system-ui, sans-serif; + --font-editor: 'MiSans Mono', Consolas, monospace; + --font-preview: 'Noto Serif SC', 'Source Han Serif SC', serif; +} +:root[data-font='18'] { + --font-ui: 'Alibaba PuHuiTi', 'Microsoft YaHei', sans-serif; + --font-editor: 'JetBrains Mono', Consolas, monospace; + --font-preview: 'Alibaba PuHuiTi', 'Noto Serif SC', serif; +} +:root[data-font='19'] { + --font-ui: 'Smiley Sans', 'Microsoft YaHei', sans-serif; + --font-editor: 'JetBrains Mono', Consolas, monospace; + --font-preview: 'Smiley Sans', 'Noto Serif SC', serif; +} +:root[data-font='20'] { + --font-ui: system-ui, sans-serif; + --font-editor: 'Maple Mono', 'Cascadia Code', Consolas, monospace; + --font-preview: 'Source Serif 4', Georgia, serif; +} +:root[data-font='21'] { + --font-ui: 'Fira Sans', system-ui, sans-serif; + --font-editor: 'Fira Code', 'Fira Mono', Consolas, monospace; + --font-preview: 'Fira Sans', Georgia, serif; +} +:root[data-font='22'] { + --font-ui: 'Roboto', system-ui, sans-serif; + --font-editor: 'Roboto Mono', Consolas, monospace; + --font-preview: 'Roboto Slab', Georgia, serif; +} +:root[data-font='23'] { + --font-ui: 'Open Sans', system-ui, sans-serif; + --font-editor: 'Source Code Pro', Consolas, monospace; + --font-preview: 'Merriweather', Georgia, serif; +} +:root[data-font='24'] { + --font-ui: 'Lato', system-ui, sans-serif; + --font-editor: 'Ubuntu Mono', Consolas, monospace; + --font-preview: 'Lora', Georgia, serif; +} +:root[data-font='25'] { + --font-ui: 'Montserrat', system-ui, sans-serif; + --font-editor: 'Space Mono', Consolas, monospace; + --font-preview: 'Playfair Display', Georgia, serif; +} +:root[data-font='26'] { + --font-ui: 'Merriweather Sans', system-ui, sans-serif; + --font-editor: 'Inconsolata', Consolas, monospace; + --font-preview: 'Merriweather', Georgia, serif; +} +:root[data-font='27'] { + --font-ui: system-ui, sans-serif; + --font-editor: 'Source Code Pro', monospace; + --font-preview: 'Crimson Text', 'Times New Roman', serif; +} +:root[data-font='28'] { + --font-ui: 'Literata', Georgia, serif; + --font-editor: 'IBM Plex Mono', Consolas, monospace; + --font-preview: 'Literata', Georgia, serif; +} +:root[data-font='29'] { + --font-ui: system-ui, sans-serif; + --font-editor: 'Inconsolata', 'Consolas', monospace; + --font-preview: Georgia, 'Noto Serif SC', serif; +} +:root[data-font='30'] { + --font-ui: system-ui, sans-serif; + --font-editor: 'Anonymous Pro', 'Courier New', monospace; + --font-preview: 'Palatino Linotype', Palatino, serif; +} +:root[data-font='31'] { + --font-ui: system-ui, sans-serif; + --font-editor: 'Fantasque Sans Mono', Consolas, monospace; + --font-preview: 'Charter', 'Noto Serif SC', serif; +} + +:root[data-bg='4'] .toolbar, +:root[data-bg='4'] .tab-bar, +:root[data-bg='4'] .sidebar { + backdrop-filter: blur(8px); +} diff --git a/src/appearance.ts b/src/appearance.ts new file mode 100644 index 0000000..c417292 --- /dev/null +++ b/src/appearance.ts @@ -0,0 +1,74 @@ +/** 背景 id 0–22,对应 `appearance-styles.css` 中 `:root[data-bg]`。 */ +export const BACKGROUNDS: { id: string; name: string }[] = [ + { id: '0', name: '云雾灰' }, + { id: '1', name: '羊皮纸' }, + { id: '2', name: '深海蓝' }, + { id: '3', name: '森绿' }, + { id: '4', name: '极光渐变' }, + { id: '5', name: '极简粉' }, + { id: '6', name: '暗夜橙' }, + { id: '7', name: '薰衣草' }, + { id: '8', name: '暖石褐' }, + { id: '9', name: '星夜紫' }, + { id: '10', name: '霜青' }, + { id: '11', name: '玫瑰灰' }, + { id: '12', name: '石墨黑' }, + { id: '13', name: '薄荷冰' }, + { id: '14', name: '焦糖拿铁' }, + { id: '15', name: '青瓷' }, + { id: '16', name: '暮光紫灰' }, + { id: '17', name: '沙岸米' }, + { id: '18', name: '松烟墨' }, + { id: '19', name: '樱粉雾' }, + { id: '20', name: '钢蓝灰' }, + { id: '21', name: '苔痕绿' }, + { id: '22', name: 'Kindle 纸感' } +] + +/** 字体 id 0–31,对应 `:root[data-font]`。 */ +export const FONTS: { id: string; name: string }[] = [ + { id: '0', name: '系统默认' }, + { id: '1', name: '衬线阅读' }, + { id: '2', name: '等宽代码' }, + { id: '3', name: '人文宋黑' }, + { id: '4', name: 'IBM Plex' }, + { id: '5', name: 'Helvetica 系' }, + { id: '6', name: 'Georgia 系' }, + { id: '7', name: 'Verdana 系' }, + { id: '8', name: 'Courier 打字机' }, + { id: '9', name: 'Segoe UI 系' }, + { id: '10', name: '思源黑体优先' }, + { id: '11', name: '圆体休闲' }, + { id: '12', name: '苹方优先' }, + { id: '13', name: '冬青黑体' }, + { id: '14', name: '霞鹜文楷' }, + { id: '15', name: 'HarmonyOS Sans' }, + { id: '16', name: 'OPPO Sans' }, + { id: '17', name: '小米兰亭' }, + { id: '18', name: '阿里巴巴普惠' }, + { id: '19', name: '得意黑' }, + { id: '20', name: 'Maple Mono' }, + { id: '21', name: 'Fira Sans + Mono' }, + { id: '22', name: 'Roboto 系' }, + { id: '23', name: 'Open Sans 系' }, + { id: '24', name: 'Lato 系' }, + { id: '25', name: 'Montserrat 系' }, + { id: '26', name: 'Merriweather 阅读' }, + { id: '27', name: 'Crimson Text' }, + { id: '28', name: 'Literata 阅读' }, + { id: '29', name: 'Inconsolata 等宽' }, + { id: '30', name: 'Anonymous Pro' }, + { id: '31', name: 'Fantasque Sans Mono' } +] + +export type ViewMode = 'edit' | 'read' | 'hybrid' + +export function validBackgroundId(s: string | null): string | null { + if (s === null) return null + return /^([0-9]|1[0-9]|2[0-2])$/.test(s) ? s : null +} + +export function validFontId(s: string | null): string | null { + if (s === null) return null + return /^([0-9]|[12][0-9]|3[01])$/.test(s) ? s : null +} diff --git a/src/colorPresets.ts b/src/colorPresets.ts new file mode 100644 index 0000000..3aacf29 --- /dev/null +++ b/src/colorPresets.ts @@ -0,0 +1,11 @@ +/** 颜色选择器预设(按行分组展示) */ +export const COLOR_SWATCH_ROWS: string[][] = [ + ['#000000', '#1c1917', '#292524', '#44403c', '#57534e', '#78716c', '#a8a29e', '#d6d3d1'], + ['#450a0a', '#7f1d1d', '#b91c1c', '#dc2626', '#f87171', '#fecaca', '#fee2e2', '#fef2f2'], + ['#431407', '#9a3412', '#c2410c', '#ea580c', '#fb923c', '#fed7aa', '#ffedd5', '#fff7ed'], + ['#422006', '#a16207', '#ca8a04', '#eab308', '#fde047', '#fef9c3', '#fefce8', '#fefce8'], + ['#14532d', '#166534', '#15803d', '#22c55e', '#86efac', '#bbf7d0', '#dcfce7', '#f0fdf4'], + ['#134e4a', '#0f766e', '#0d9488', '#14b8a6', '#5eead4', '#ccfbf1', '#ecfdf5', '#f0fdfa'], + ['#1e3a8a', '#1d4ed8', '#2563eb', '#3b82f6', '#93c5fd', '#bfdbfe', '#dbeafe', '#eff6ff'], + ['#4c1d95', '#6d28d9', '#7c3aed', '#8b5cf6', '#c4b5fd', '#ddd6fe', '#ede9fe', '#f5f3ff'] +] diff --git a/src/downloadTextFile.ts b/src/downloadTextFile.ts new file mode 100644 index 0000000..ab4cb91 --- /dev/null +++ b/src/downloadTextFile.ts @@ -0,0 +1,28 @@ +/** + * 当 File System Access「另存为」不可用时(典型:HTTP 公网域名), + * 用浏览器下载代替,避免保存流程完全失败。 + */ +export function downloadTextFile(fileName: string, content: string): void { + const safe = fileName.replace(/[/\\<>|:*?"\u0000-\u001f]/g, '_').trim() || '未命名.md' + const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = safe + a.style.display = 'none' + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +/** 当前页面不在「安全上下文」时(如 http://域名,非 localhost),FSA 不可用。 */ +export function lacksFileSystemAccessContext(): boolean { + return typeof globalThis.isSecureContext === 'boolean' && !globalThis.isSecureContext +} + +export function fileNameFromPath(filePath: string | null): string { + if (!filePath) return '未命名.md' + const base = filePath.replace(/\\/g, '/').split('/').pop()?.trim() || '未命名.md' + return base || '未命名.md' +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..7466df2 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,39 @@ +/// + +import type { + ListDirResult, + OpenFileResult, + OpenFolderResult, + OpenImageResult, + SaveDialogResult +} from './types/ipc-types' + +export interface MyReaderApi { + openFileDialog: () => Promise + openFolderDialog: () => Promise + openImageDialog: () => Promise + saveFileDialog: (defaultPath?: string) => Promise + readFile: (filePath: string) => Promise + writeFile: (filePath: string, content: string) => Promise + listDirectory: (dirPath: string) => Promise + parentDirectory: (dirPath: string) => Promise + resolveRelativePath: (baseFilePath: string, href: string) => Promise + openExternal: (url: string) => Promise + defaultDirectory: () => Promise + deleteFile: (filePath: string) => Promise + renamePath: (oldPath: string, newName: string) => Promise<{ newPath: string }> + allowClose: () => void + onRequestClose: (handler: () => void) => () => void + onMenuAction: (handler: (action: string) => void) => () => void +} + +declare global { + interface Window { + myreader?: MyReaderApi + showOpenFilePicker?: (options?: OpenFilePickerOptions) => Promise + showSaveFilePicker?: (options?: SaveFilePickerOptions) => Promise + showDirectoryPicker?: (options?: DirectoryPickerOptions) => Promise + } +} + +export {} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..0a54d31 --- /dev/null +++ b/src/index.css @@ -0,0 +1,1583 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; + overflow: hidden; + background: var(--app-bg, #e5e7eb); + color: var(--text, #111827); +} + +body { + font-family: var(--font-ui, system-ui, sans-serif); +} + +.app-root { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + color: var(--text); + background: var(--app-bg); + font-family: var(--font-ui, system-ui, sans-serif); +} + +/* Kindle 纸感:叠在纯色底上,避免覆盖 index 中的 background 简写 */ +html[data-bg='22'] .app-root { + background-color: var(--app-bg); + background-image: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.014) 2px, + rgba(0, 0, 0, 0.014) 3px + ), + radial-gradient(ellipse 120% 90% at 50% -10%, rgba(255, 255, 255, 0.55) 0%, transparent 55%), + radial-gradient(ellipse 80% 60% at 100% 100%, rgba(0, 0, 0, 0.04) 0%, transparent 50%); +} + +.no-api { + padding: 24px; +} + +.tab-bar { + display: flex; + flex: 0 0 auto; + flex-wrap: nowrap; + align-items: stretch; + gap: 4px; + padding: 6px 8px 0; + border-bottom: 1px solid var(--border); + background: var(--panel-bg); + position: relative; + z-index: 30; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.tab-bar::-webkit-scrollbar { + height: 6px; +} + +.tab-bar::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--muted) 50%, transparent); + border-radius: 3px; +} + +.tab { + display: inline-flex; + flex: 0 0 auto; + align-items: stretch; + max-width: 320px; + border: 1px solid transparent; + border-bottom: none; + border-radius: 6px 6px 0 0; + background: transparent; + color: inherit; + overflow: hidden; +} + +.tab.active { + background: var(--app-bg); + border-color: var(--border); +} + +.tab-main { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 4px 6px 10px; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; + overflow: hidden; +} + +.tab-close { + flex: 0 0 28px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 16px; + line-height: 1; +} + +.tab-close:hover { + color: var(--text); + background: var(--hover); +} + +.tab-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.tab.active .tab-title { + font-size: 16px; + font-weight: 700; +} + +.tab .dot { + color: var(--accent); + font-size: 18px; + line-height: 1; +} + +.toolbar { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--panel-bg); + position: relative; + z-index: 120; + isolation: isolate; +} + +.save-toast { + flex: 1 1 100%; + margin: 0 0 4px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--panel-bg)); + color: var(--text); + font-size: 13px; + line-height: 1.45; +} + +.toolbar button, +.sidebar-tabs button, +.path-row button, +.dir-item, +.outline-title, +.outline-level--branch { + font: inherit; + cursor: pointer; +} + +.toolbar button { + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); +} + +.toolbar button:hover { + border-color: var(--accent); +} + +.toolbar-tail { + margin-left: auto; + display: flex; + flex-wrap: nowrap; + align-items: flex-end; + gap: 8px; + flex: 0 0 auto; +} + +.bg-select { + flex: 0 0 7.25rem; + width: 7.25rem; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 3px; + margin-left: 0; + font-size: 11px; + color: var(--muted); +} + +.bg-select select, +.bg-select .toolbar-mini-input { + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 3px 6px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); + position: relative; + z-index: 130; + font: inherit; + font-size: 12px; +} + +.bg-select .toolbar-mini-input { + -moz-appearance: textfield; +} + +.bg-select .toolbar-mini-input::-webkit-outer-spin-button, +.bg-select .toolbar-mini-input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; +} + +.main { + flex: 1; + min-height: 0; + display: flex; + position: relative; + overflow: hidden; +} + +.sidebar { + flex: 0 0 auto; + flex-shrink: 0; + align-self: stretch; + border-right: 1px solid var(--border); + background: var(--panel-bg); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.sidebar-resize { + width: 5px; + cursor: col-resize; + flex: 0 0 5px; + background: transparent; +} + +.sidebar-resize:hover { + background: var(--accent); + opacity: 0.35; +} + +.sidebar-reopen { + flex: 0 0 22px; + width: 22px; + border: none; + border-right: 1px solid var(--border); + background: var(--panel-bg); + color: var(--text); + cursor: pointer; + font-size: 16px; +} + +.sidebar-reopen:hover { + background: var(--hover); +} + +.sidebar-tabs { + display: flex; + border-bottom: 1px solid var(--border); +} + +.sidebar-tabs button { + flex: 1; + padding: 8px; + border: none; + background: transparent; + color: var(--muted); + border-bottom: 2px solid transparent; +} + +.sidebar-tabs button.on { + color: var(--text); + border-bottom-color: var(--accent); +} + +.file-pane, +.outline { + flex: 1; + min-height: 0; + overflow: auto; +} + +.path-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + padding: 8px; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.path-row-actions { + display: flex; + flex-direction: column; + gap: 4px; + flex-shrink: 0; +} + +.path-row-actions button { + width: 100%; + min-width: 5.5rem; + white-space: nowrap; +} + +.path-row button { + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); +} + +.path-row button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.path { + flex: 1; + font-size: 12px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dir-error { + padding: 8px; + color: #c62828; + font-size: 13px; +} + +.dir-list, +.outline { + list-style: none; + margin: 0; + padding: 4px 0; +} + +.dir-item { + width: 100%; + text-align: left; + padding: 6px 10px; + border: none; + background: transparent; + color: inherit; + display: flex; + align-items: center; + gap: 8px; +} + +.outline-title, +.outline-level--branch { + font: inherit; + cursor: pointer; +} + +.outline-entry { + display: flex; + align-items: stretch; + gap: 10px; + padding: 4px 8px 4px 6px; + border-radius: 8px; + transition: background 0.12s ease; +} + +.outline-entry:hover { + background: var(--hover); +} + +.outline-entry--folded .outline-title { + color: var(--muted); +} + +.outline-level { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 30px; + padding: 0 6px; + border-radius: 8px; + border: 2px solid var(--border); + background: var(--btn-bg); + color: var(--text); + font-weight: 700; + line-height: 1; + box-sizing: border-box; +} + +.outline-level--leaf { + cursor: default; + border-style: solid; + opacity: 0.92; +} + +.outline-level--branch { + position: relative; + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--btn-bg)); + color: var(--accent); +} + +.outline-level--branch::after { + content: ''; + position: absolute; + right: 4px; + bottom: 4px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 2px var(--btn-bg); +} + +.outline-level--branch.outline-level--collapsed { + border-style: dashed; + background: var(--btn-bg); + color: var(--muted); + border-color: var(--muted); +} + +.outline-level--branch.outline-level--collapsed::after { + background: transparent; + border: 2px solid var(--muted); + box-shadow: none; +} + +.outline-level-num { + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.outline-level--h1 { + min-width: 34px; + height: 34px; + font-size: 15px; + border-width: 2px; +} + +.outline-level--h1 .outline-level-num { + font-size: 15px; +} + +.outline-level--h2 { + min-width: 32px; + height: 32px; +} + +.outline-level--h3 { + min-width: 30px; + height: 28px; + font-weight: 650; +} + +.outline-level--h3 .outline-level-num { + font-size: 13px; +} + +.outline-level--h4, +.outline-level--h5, +.outline-level--h6 { + min-width: 28px; + height: 26px; + font-weight: 600; + color: var(--muted); + border-color: color-mix(in srgb, var(--border) 90%, var(--muted)); +} + +.outline-level--h4 .outline-level-num, +.outline-level--h5 .outline-level-num, +.outline-level--h6 .outline-level-num { + font-size: 12px; +} + +.outline-title { + flex: 1; + min-width: 0; + text-align: left; + padding: 6px 4px 6px 0; + border: none; + background: transparent; + color: inherit; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + white-space: normal; + word-break: break-word; +} + +.outline-entry--h1 .outline-title { + font-weight: 650; + font-size: 14px; +} + +.outline-entry--h2 .outline-title { + font-weight: 600; + font-size: 13px; +} + +.outline-entry--h3 .outline-title { + font-weight: 550; + font-size: 13px; +} + +.outline-entry--h4 .outline-title, +.outline-entry--h5 .outline-title, +.outline-entry--h6 .outline-title { + font-weight: 500; + font-size: 12px; + color: var(--muted); +} + +.outline-panel { + padding: 6px 4px; +} + +.outline-panel-wrap { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.outline-pane { + padding: 0; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.outline-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 8px 6px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.outline-toolbar-title { + flex: 1; + font-size: 12px; + color: var(--muted); +} + +.outline-toolbar-btn { + padding: 3px 8px; + font-size: 11px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); + cursor: pointer; + font: inherit; +} + +.outline-toolbar-btn:hover:not(:disabled) { + border-color: var(--accent); +} + +.outline-toolbar-btn:disabled { + opacity: 0.45; + cursor: default; +} + +.outline-toolbar-meta { + color: var(--muted); + font-weight: 400; +} + +.outline-tree--root { + overflow: auto; + flex: 1; + min-height: 0; +} + +.outline-tree-row { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + padding: 2px 6px 2px 4px; + border-radius: 6px; +} + +.outline-tree-row:hover { + background: var(--hover); +} + +.outline-fold-btn { + flex: 0 0 22px; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--btn-bg); + color: var(--accent); + font-size: 12px; + line-height: 1; + cursor: pointer; + font: inherit; +} + +.outline-fold-btn:hover { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--btn-bg)); +} + +.outline-fold-btn.is-collapsed { + color: var(--muted); +} + +.outline-fold-spacer { + flex: 0 0 22px; + width: 22px; +} + +.outline-tree-title { + flex: 1; + min-width: 0; + text-align: left; + border: none; + background: transparent; + color: var(--text); + font: inherit; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.outline-tree-title:hover { + color: var(--accent); +} + +.outline-tree-title--h1 { + font-weight: 700; + font-size: 14px; +} + +.outline-tree-title--h2 { + font-weight: 650; + font-size: 13.5px; +} + +.outline-tree-title--h3 { + font-weight: 600; + font-size: 13px; +} + +.outline-tree-title--h4, +.outline-tree-title--h5, +.outline-tree-title--h6 { + font-weight: 500; + font-size: 12.5px; + color: var(--muted); +} + +.outline-tree { + list-style: none; + margin: 0; + padding: 4px 0 8px; +} + +.outline-tree .outline-tree { + margin: 2px 0 4px 6px; + padding: 2px 0 2px 12px; + border-left: 2px solid color-mix(in srgb, var(--accent) 22%, var(--border)); +} + +.outline-tree-node { + margin: 0; +} + +.outline-empty { + margin: 12px 10px; + font-size: 13px; + color: var(--muted); +} + +.outline-simple { + list-style: none; + margin: 0; + padding: 6px 4px 10px; +} + +.outline-simple-item { + width: 100%; + text-align: left; + border: none; + background: transparent; + color: var(--text); + font: inherit; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.outline-simple-item:hover { + background: var(--hover); +} + +.outline-simple-item--h1 { + font-weight: 700; + font-size: 14px; +} + +.outline-simple-item--h2 { + font-weight: 650; + font-size: 13.5px; +} + +.outline-simple-item--h3 { + font-weight: 600; + font-size: 13px; + color: color-mix(in srgb, var(--text) 88%, var(--muted)); +} + +.outline-simple-item--h4, +.outline-simple-item--h5, +.outline-simple-item--h6 { + font-weight: 500; + font-size: 12.5px; + color: var(--muted); +} + +.history-pane { + padding-top: 4px; +} + +.history-list { + list-style: none; + margin: 0; + padding: 0 4px 8px; +} + +.history-item { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + text-align: left; + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; +} + +.history-item:hover { + background: var(--hover); +} + +.history-item-name { + font-weight: 600; + font-size: 13px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-item-path { + font-size: 11px; + color: var(--muted); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry-kind { + flex: 0 0 24px; + font-size: 10px; + font-weight: 700; + text-align: center; + border-radius: 4px; + padding: 3px 0; + line-height: 1.1; + color: #fff; +} + +.entry-kind--dir { + background: #4f46e5; +} + +.entry-kind--file { + background: #047857; +} + +.entry-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.md-format-wrap { + width: 100%; + border-bottom: 1px solid var(--border); + background: var(--panel-bg); + position: relative; + z-index: 25; +} + +.md-format-bar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 6px 8px; + max-height: 120px; + overflow-y: auto; +} + +.md-format-bar button { + padding: 4px 8px; + font-size: 12px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); + cursor: pointer; + white-space: nowrap; +} + +.md-format-bar button:hover { + border-color: var(--accent); +} + +.dir-item[data-kind='dir'] { + font-weight: 600; +} + +.content { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; + z-index: 1; + overflow: hidden; +} + +.content-empty { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + color: var(--muted); + font-size: 15px; + text-align: center; +} + +.editor-host { + flex: 1; + min-height: 0; +} + +.editor-host .cm-editor { + height: 100%; +} + +.preview-outer { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 12px 16px 16px; +} + +.preview-shell { + position: relative; + flex: 0 1 auto; + min-width: 0; + width: 100%; +} + +.preview-shell--tight { + padding: 0 8px 8px; +} + +.preview-resizer { + position: absolute; + top: 0; + right: -10px; + width: 16px; + height: 100%; + cursor: ew-resize; + z-index: 5; + border-radius: 4px; + border: 2px solid var(--accent); + background: var(--btn-bg); + opacity: 0.95; +} + +.preview-resizer:hover { + opacity: 1; + filter: brightness(1.05); +} + +.preview-resizer::after { + content: '‖'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--accent); + pointer-events: none; +} + +.preview { + flex: 0 0 auto; + overflow: visible; + padding: 24px 32px 48px; + width: 100%; + font-size: 16px; + line-height: var(--preview-line-height, 1.65); + background: var(--preview-bg); + color: var(--preview-text); + border-radius: 8px; + box-shadow: var(--preview-shadow); + font-family: var(--font-preview, var(--font-ui, Georgia, serif)); +} + +.preview code { + font-family: 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 0.9em; + background: color-mix(in srgb, var(--code-bg) 85%, var(--border)); + padding: 0.12em 0.35em; + border-radius: 4px; +} + +.preview .md-code-wrap { + position: relative; + margin: 1.1em 0; + border: 1px solid color-mix(in srgb, var(--border) 90%, #000); + border-radius: 6px; + background: var(--code-bg); + overflow: hidden; +} + +.preview .md-code-wrap .md-code-lang { + position: absolute; + top: 0; + right: 0; + padding: 3px 10px; + font-size: 11px; + color: var(--muted); + text-transform: lowercase; + letter-spacing: 0.02em; + pointer-events: none; + user-select: none; +} + +.preview .md-code-wrap pre.hljs { + margin: 0; + padding: 16px 18px; + background: transparent; + border-radius: 0; + overflow-x: auto; +} + +.preview .md-code-wrap pre.hljs code { + font-family: 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 13px; + line-height: 1.55; + background: transparent; + padding: 0; +} + +.preview .md-diagram-wrap { + margin: 1.5em 0; + padding: 20px 16px 24px; + background: var(--preview-bg); + border: 1px solid color-mix(in srgb, var(--border) 85%, #000); + border-radius: 6px; + overflow-x: auto; + overflow-y: hidden; + text-align: center; + contain: layout style; +} + +.preview .md-diagram-wrap pre.mermaid { + margin: 0; + padding: 0; + border: none; + background: transparent; + text-align: center; +} + +.preview .md-diagram-wrap .mermaid-render-host { + display: flex; + justify-content: center; + align-items: center; + min-height: 4rem; + width: 100%; + overflow: hidden; +} + +.preview .md-diagram-wrap .mermaid-placeholder { + min-height: 4rem; + width: 100%; +} + +.preview .md-diagram-wrap .mermaid-render-host svg { + max-width: 100%; + height: auto; + display: block; +} + +.preview .md-diagram-wrap .mermaid-error { + margin: 0; + padding: 12px; + text-align: left; + font-size: 12px; + color: #b91c1c; + background: color-mix(in srgb, #fecaca 40%, transparent); + border-radius: 4px; + white-space: pre-wrap; + word-break: break-word; +} + +.hybrid-preview .preview .md-diagram-wrap { + width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.preview img { + max-width: 100%; + height: auto; + display: block; + margin: 1em auto; + border-radius: 6px; + box-shadow: 0 2px 12px rgba(15, 23, 42, 0.1); +} + +.preview h1, +.preview h2, +.preview h3, +.preview h4, +.preview h5, +.preview h6 { + font-family: inherit; + color: inherit; + margin-top: 1.25em; + margin-bottom: 0.55em; + line-height: 1.25; +} + +.preview h1 { + font-size: 2rem; + font-weight: 700; + margin-top: 0.35em; + padding-bottom: 0.2em; + border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); +} + +.preview h2 { + font-size: 1.55rem; + font-weight: 650; +} + +.preview h3 { + font-size: 1.28rem; + font-weight: 600; +} + +.preview h4 { + font-size: 1.12rem; + font-weight: 600; +} + +.preview h5 { + font-size: 1.02rem; + font-weight: 600; +} + +.preview h6 { + font-size: 0.95rem; + font-weight: 600; + color: var(--muted); +} + +.preview pre { + overflow: auto; + padding: 12px; + border-radius: 6px; + background: var(--code-bg); +} + +.preview .hljs, +.preview .hljs code { + font-family: 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace !important; +} + +.preview a { + color: #4183c4; + text-decoration: none; + cursor: pointer; +} + +.preview a:hover { + text-decoration: underline; +} + +.preview a.md-internal-link { + color: #4183c4; + border-bottom: 1px dashed color-mix(in srgb, #4183c4 55%, transparent); +} + +.preview a.md-internal-link:hover { + border-bottom-style: solid; +} + +.preview hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.25em 0; + height: 0; + background: none; +} + +.hybrid-row { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + align-items: stretch; +} + +.hybrid-editor { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); +} + +.hybrid-sash { + flex: 0 0 6px; + cursor: col-resize; + background: transparent; +} + +.hybrid-sash:hover { + background: var(--accent); + opacity: 0.25; +} + +.hybrid-preview { + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + display: flex; + justify-content: center; +} + +.hybrid-preview-shell { + position: relative; +} + +.hybrid-line-pulse { + position: absolute; + left: 8px; + right: 8px; + height: 1.75em; + transform: translateY(-50%); + pointer-events: none; + z-index: 2; + border-radius: 6px; + background: rgba(250, 204, 21, 0.42); + box-shadow: 0 0 0 1px rgba(234, 179, 8, 0.35); + animation: hybrid-line-pulse 1.35s ease-in-out forwards; +} + +@keyframes hybrid-line-pulse { + 0%, + 100% { + opacity: 0; + } + 12%, + 30% { + opacity: 1; + } + 42% { + opacity: 0; + } + 54%, + 72% { + opacity: 1; + } + 84%, + 100% { + opacity: 0; + } +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + width: min(420px, calc(100% - 32px)); + background: var(--panel-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px 18px; + box-shadow: var(--preview-shadow); +} + +.modal-title { + margin: 0 0 8px; + font-size: 16px; +} + +.modal-body { + margin: 0 0 16px; + font-size: 14px; + color: var(--muted); +} + +.modal-text-input { + width: 100%; + box-sizing: border-box; + margin: 0 0 16px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); + font: inherit; +} + +.modal-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.modal-actions button { + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); + cursor: pointer; +} + +.modal-actions button.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.modal-actions a.modal-link-btn { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + text-decoration: none; + font: inherit; +} + +.modal-actions a.modal-link-btn:hover { + filter: brightness(1.06); +} + +.modal--wide { + width: min(520px, calc(100% - 32px)); +} + +.web-notes-body { + max-height: min(60vh, 420px); + overflow-y: auto; + line-height: 1.55; + text-align: left; +} + +.web-notes-body p { + margin: 0 0 12px; +} + +.web-notes-body p:last-child { + margin-bottom: 0; +} + +.web-notes-body code { + font-size: 0.92em; + padding: 1px 4px; + border-radius: 4px; + background: var(--btn-bg); +} + +.web-notes-footer { + margin-top: 4px !important; + font-size: 13px; + color: var(--muted); +} + +.web-notes-list { + margin: 0 0 12px; + padding-left: 1.25em; +} + +.web-notes-list li { + margin-bottom: 6px; +} + +.web-notes-muted { + font-size: 13px; + color: var(--muted); +} + +.modal-hint { + margin: 0 0 12px; + font-size: 13px; + color: var(--muted); + line-height: 1.45; +} + +.modal-field { + display: block; + margin-bottom: 12px; +} + +.modal-field .modal-text-input { + margin-bottom: 0; +} + +.modal-label { + display: block; + font-size: 12px; + color: var(--muted); + margin-bottom: 4px; +} + +.modal-row-btns { + margin: -4px 0 12px; +} + +.modal-row-btns button { + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); + cursor: pointer; + font: inherit; +} + +.modal-row-btns button:hover { + border-color: var(--accent); +} + +.color-picker-native { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.color-picker-spectrum { + width: 56px; + height: 36px; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + background: var(--btn-bg); +} + +.color-swatch-grid { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} + +.color-swatch-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.color-swatch { + width: 22px; + height: 22px; + border-radius: 4px; + border: 2px solid var(--border); + padding: 0; + cursor: pointer; + box-sizing: border-box; +} + +.color-swatch:hover { + transform: scale(1.08); +} + +.color-swatch.selected { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.ctx-menu { + position: fixed; + z-index: 900; + min-width: 120px; + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: var(--preview-shadow); + padding: 4px; +} + +.ctx-item { + width: 100%; + text-align: left; + border: none; + background: transparent; + color: var(--text); + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; + font: inherit; +} + +.ctx-item.danger { + color: #b91c1c; +} + +.ctx-item:hover { + background: var(--hover); +} + +.preview .md-table-wrap { + overflow-x: auto; + margin: 1.25em 0; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06); +} + +.preview table { + width: 100%; + border-collapse: collapse; + font-size: 0.94em; + line-height: 1.55; +} + +.preview thead th { + background: color-mix(in srgb, var(--accent) 10%, var(--preview-bg)); + font-weight: 650; + text-align: left; + padding: 10px 14px; + border-bottom: 2px solid var(--border); + white-space: nowrap; +} + +.preview tbody td { + padding: 9px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + vertical-align: top; +} + +.preview tbody tr:nth-child(even) td { + background: color-mix(in srgb, var(--text) 3%, transparent); +} + +.preview tbody tr:hover td { + background: color-mix(in srgb, var(--accent) 7%, transparent); +} + +.preview tbody tr:last-child td { + border-bottom: none; +} + + +.preview pre.mermaid:not(.md-diagram-wrap pre.mermaid) { + margin: 1.5em 0; + padding: 18px 16px; + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: auto; + text-align: center; +} + +.preview-anchor-flash { + animation: preview-anchor-flash 1.4s ease-out; +} + +@keyframes preview-anchor-flash { + 0%, + 100% { + background-color: transparent; + box-shadow: none; + } + 15%, + 40% { + background-color: rgba(250, 204, 21, 0.35); + box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.25); + border-radius: 4px; + } + 55% { + background-color: transparent; + box-shadow: none; + } + 70% { + background-color: rgba(250, 204, 21, 0.28); + box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.2); + border-radius: 4px; + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..269af3b --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,30 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import { validBackgroundId, validFontId } from './appearance' +import { installBrowserApi } from './platform/browserApi' +import './appearance-styles.css' +import './index.css' + +installBrowserApi() + +{ + const bg = + localStorage.getItem('myreader-bg-id') ?? + localStorage.getItem('myreader-theme') ?? + localStorage.getItem('myreader-bg') ?? + '1' + const font = localStorage.getItem('myreader-font-id') ?? '0' + const line = localStorage.getItem('myreader-preview-line-height') + const lineN = line ? Number.parseFloat(line) : NaN + const lineH = Number.isFinite(lineN) ? String(Math.min(3, Math.max(1, lineN))) : '1.5' + document.documentElement.dataset.bg = validBackgroundId(bg) ?? '1' + document.documentElement.dataset.font = validFontId(font) ?? '0' + document.documentElement.style.setProperty('--preview-line-height', lineH) +} + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/markdown.ts b/src/markdown.ts new file mode 100644 index 0000000..31dbb40 --- /dev/null +++ b/src/markdown.ts @@ -0,0 +1,406 @@ +import MarkdownIt from 'markdown-it' +import multimdTable from 'markdown-it-multimd-table' +import hljs from 'highlight.js' +import DOMPurify from 'dompurify' +import { getCachedImageBlobUrl, webVfs } from './platform/webVfs' +import 'highlight.js/styles/github.min.css' + +const MERMAID_LANG = new Set(['mermaid', 'flowchart', 'sequencediagram', 'sequence']) +const MERMAID_START = + /^\s*(sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|gantt|pie|gitGraph|journey|flowchart\s|graph\s|mindmap|timeline|quadrantChart|xychart-beta|block-beta)/i + +function isMermaidFence(lang: string, content: string): boolean { + const l = lang.toLowerCase() + if (MERMAID_LANG.has(l)) return true + if (!lang.trim() && MERMAID_START.test(content)) return true + return false +} + +function highlightCodeBlock(content: string, lang: string): string { + const trimmed = (lang || '').trim() + if (trimmed && hljs.getLanguage(trimmed)) { + try { + return hljs.highlight(content, { language: trimmed, ignoreIllegals: true }).value + } catch { + /* fall through */ + } + } + if (trimmed) { + try { + return hljs.highlightAuto(content).value + } catch { + /* fall through */ + } + } + return md.utils.escapeHtml(content) +} + +const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + highlight: function (this: MarkdownIt, str: string, lang: string) { + const body = highlightCodeBlock(str.replace(/\n$/, ''), lang) + return `
    ${body}
    ` + } +}) + +md.use(multimdTable, { multiline: true, rowspan: true, headerless: false }) + +/** 允许本地 file:// 图片与链接(markdown-it 默认禁止 file: 协议)。 */ +const defaultValidateLink = md.validateLink.bind(md) +md.validateLink = (url: string): boolean => { + if (/^file:/i.test(url)) return true + if (/^blob:/i.test(url)) return true + return defaultValidateLink(url) +} + +/** 禁用 Setext 标题,避免 `---` 等被当成 h2。 */ +md.disable(['lheading']) + +function setBlockSourceLines(token: { map?: [number, number] | null; attrSet: (n: string, v: string) => void }): void { + if (!token.map) return + const start = token.map[0] + const end = Math.max(start, token.map[1] - 1) + token.attrSet('data-source-line', String(start)) + token.attrSet('data-source-line-end', String(end)) +} + +const defaultHeadingOpen = + md.renderer.rules.heading_open ?? + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options) + } + +md.renderer.rules.heading_open = (tokens, idx, options, env, self) => { + const token = tokens[idx] + setBlockSourceLines(token) + token.attrSet('id', slugForLine(token.map ? token.map[0] : 0)) + return defaultHeadingOpen(tokens, idx, options, env, self) +} + +function wrapBlockOpenRule(ruleName: 'paragraph_open' | 'list_item_open' | 'blockquote_open'): void { + const prev = md.renderer.rules[ruleName] + md.renderer.rules[ruleName] = (tokens, idx, options, env, self) => { + setBlockSourceLines(tokens[idx]!) + if (prev) return prev(tokens, idx, options, env, self) + return self.renderToken(tokens, idx, options) + } +} + +wrapBlockOpenRule('paragraph_open') +wrapBlockOpenRule('list_item_open') +wrapBlockOpenRule('blockquote_open') + +const defaultTableOpen = md.renderer.rules.table_open +const defaultTableClose = md.renderer.rules.table_close + +md.renderer.rules.table_open = (tokens, idx, options, env, self) => { + setBlockSourceLines(tokens[idx]!) + const inner = defaultTableOpen + ? defaultTableOpen(tokens, idx, options, env, self) + : '' + return `
    ${inner}` +} + +md.renderer.rules.table_close = (tokens, idx, options, env, self) => { + const inner = defaultTableClose + ? defaultTableClose(tokens, idx, options, env, self) + : '
    ' + return `${inner}
    ` +} + +/** 1×1 透明占位图:浏览器禁止在 中直接加载 file://。 */ +const IMG_PLACEHOLDER = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + +function resolveImageDiskPath(src: string, baseFilePath: string | null | undefined): string | null { + return webVfs.resolveImageAbsPath(src, baseFilePath?.trim() || null) +} + +function normalizeImageSrc(src: string, baseFilePath: string | null | undefined): string { + const s = src.trim() + if (!s) return s + if (/^(blob:|data:|https?:)/i.test(s)) return s + + const absPath = resolveImageDiskPath(s, baseFilePath) + if (!absPath) return s + + const hit = getCachedImageBlobUrl(absPath) + if (hit) return hit + + return IMG_PLACEHOLDER +} + +const defaultImage = md.renderer.rules.image! +md.renderer.rules.image = (tokens, idx, options, env, self) => { + const token = tokens[idx]! + const src = token.attrGet('src') + const base = (env as { baseFilePath?: string | null }).baseFilePath + if (src) { + const disk = resolveImageDiskPath(src, base) + if (disk) token.attrSet('data-disk-path', disk) + token.attrSet('src', normalizeImageSrc(src, base)) + } + const html = defaultImage(tokens, idx, options, env, self) + return html.replace(/ { + const token = tokens[idx] + if (!token.map) { + return defaultFence ? defaultFence(tokens, idx, options, env, self) : '' + } + const lang = (token.info || '').trim() + const raw = token.content.replace(/\n$/, '') + const codeEndLine = Math.max(token.map[0], token.map[1] - 1) + + if (isMermaidFence(lang, raw)) { + const trimmed = raw.trim() + const escaped = md.utils.escapeHtml(trimmed) + const dataSrc = md.utils.escapeHtml(encodeURIComponent(trimmed)) + return `
    ${escaped}
    ` + } + + const highlighted = highlightCodeBlock(raw, lang) + const langLabel = lang + ? `${md.utils.escapeHtml(lang.split(/\s+/)[0] ?? lang)}` + : '' + return `
    ${langLabel}
    ${highlighted}
    ` +} + +const purifyOpts: Parameters[1] = { + USE_PROFILES: { html: true }, + ADD_TAGS: ['span', 'mark', 'kbd', 'sup', 'sub', 'details', 'summary', 'font', 'div'], + ADD_ATTR: [ + 'style', + 'color', + 'open', + 'data-source-line', + 'data-source-line-end', + 'id', + 'class', + 'href', + 'data-md-href', + 'src', + 'alt', + 'width', + 'height', + 'loading', + 'decoding', + 'data-mermaid-src' + ], + ALLOWED_URI_REGEXP: + /^(?:(?:https?|mailto|tel|data|file|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i +} + +const defaultLinkOpen = md.renderer.rules.link_open +md.renderer.rules.link_open = (tokens, idx, options, env, self) => { + const token = tokens[idx]! + const href = token.attrGet('href') ?? '' + if (href && !/^https?:\/\//i.test(href) && !href.startsWith('#') && !href.startsWith('mailto:')) { + token.attrSet('class', 'md-internal-link') + token.attrSet('data-md-href', href) + } + if (defaultLinkOpen) return defaultLinkOpen(tokens, idx, options, env, self) + return self.renderToken(tokens, idx, options) +} + +export function slugForLine(line: number): string { + return `mr-line-${line}` +} + +export type RenderMarkdownEnv = { + baseFilePath?: string | null +} + +export function renderMarkdown(src: string, env?: RenderMarkdownEnv): string { + const input = typeof src === 'string' ? src : '' + const renderEnv = env ?? {} + try { + const raw = md.render(input, renderEnv) + try { + return DOMPurify.sanitize(raw, purifyOpts) + } catch (e) { + console.warn('[MyReader] DOMPurify failed', e) + return `

    ${md.utils.escapeHtml(raw.slice(0, 8000))}

    ` + } + } catch (e) { + console.warn('[MyReader] markdown render failed', e) + return `
    ${md.utils.escapeHtml(input.slice(0, 8000))}
    ` + } +} + +export type OutlineItem = { + level: number + text: string + displayText: string + /** 0-based source line */ + line: number + slug: string +} + +function outlineDisplayText(raw: string): string { + let s = raw + s = s.replace(/\*\*([^*]+)\*\*/g, '$1') + s = s.replace(/\*([^*]+)\*/g, '$1') + s = s.replace(/`([^`]+)`/g, '$1') + s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + s = s.replace(/<[^>]+>/g, '') + s = s.replace(/\s+/g, ' ').trim() + return s +} + +/** 分隔线、纯符号等不应出现在大纲。 */ +export function isValidOutlineHeading(displayText: string, rawLine: string): boolean { + const t = displayText.trim() + if (!t || t === '(空标题)') return false + if (/^[-—–_=~*`#>\s]+$/.test(t)) return false + const body = rawLine.replace(/^#{1,6}\s*/, '').trim() + if (/^[-—–_=~*\s]+$/.test(body)) return false + return true +} + +/** 仅从 ATX `#` 标题提取;`line` 为 0-based。 */ +export function extractOutline(src: string): OutlineItem[] { + const input = typeof src === 'string' ? src : '' + const lines = input.split(/\r?\n/) + const out: OutlineItem[] = [] + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] ?? '' + const m = /^(#{1,6})\s+(.+)$/.exec(raw) + if (!m) continue + const level = m[1]!.length + const text = m[2]!.trim() + const displayText = outlineDisplayText(text) || '(空标题)' + if (!isValidOutlineHeading(displayText, raw)) continue + out.push({ + level, + text, + displayText, + line: i, + slug: slugForLine(i) + }) + } + return out +} + +export type SourceLineRegion = { + line: number + lineEnd: number + el: HTMLElement +} + +export function collectSourceLineRegions(article: HTMLElement): SourceLineRegion[] { + const nodes = article.querySelectorAll('[data-source-line]') + const regions: SourceLineRegion[] = [] + for (const el of nodes) { + const start = Number(el.getAttribute('data-source-line')) + if (!Number.isFinite(start)) continue + const endRaw = el.getAttribute('data-source-line-end') + const end = endRaw !== null && Number.isFinite(Number(endRaw)) ? Number(endRaw) : start + regions.push({ line: start, lineEnd: Math.max(start, end), el }) + } + regions.sort((a, b) => a.line - b.line) + return regions +} + +/** 阅读区中与源行号对应的元素(用于高亮闪烁)。 */ +export function findPreviewAnchorForLine( + article: HTMLElement, + line: number +): HTMLElement | null { + return finestElementForLine(article, line) ?? article.querySelector(`#${slugForLine(line)}`) +} + +function finestElementForLine(article: HTMLElement, line: number): HTMLElement | null { + const nodes = article.querySelectorAll(`[data-source-line="${line}"]`) + for (const el of nodes) { + const start = Number(el.getAttribute('data-source-line')) + if (start !== line) continue + const endRaw = el.getAttribute('data-source-line-end') + const end = endRaw !== null && Number.isFinite(Number(endRaw)) ? Number(endRaw) : start + if (end > start) continue + return el + } + return null +} + +/** + * 分栏阅读区:计算源行在 shell 内的垂直中心(px)。 + * `line` 为 0-based 源行号。 + */ +export function previewLineCenterInShell( + article: HTMLElement, + shell: HTMLElement, + line: number, + totalSourceLines: number +): number | null { + const shellRect = shell.getBoundingClientRect() + + const exact = finestElementForLine(article, line) + if (exact) { + const r = exact.getBoundingClientRect() + return r.top - shellRect.top + r.height / 2 + } + + const regions = collectSourceLineRegions(article) + if (regions.length === 0) { + const h = article.offsetHeight || 1 + return ((line + 0.5) / Math.max(1, totalSourceLines)) * h + } + + let region = regions[0]! + for (const r of regions) { + if (r.line <= line) region = r + else break + } + + if (line >= region.line && line <= region.lineEnd) { + const span = Math.max(1, region.lineEnd - region.line + 1) + const offset = Math.max(0, Math.min(line - region.line, span - 1)) + const frac = (offset + 0.5) / span + const blockRect = region.el.getBoundingClientRect() + return blockRect.top - shellRect.top + frac * blockRect.height + } + + const blockRect = region.el.getBoundingClientRect() + return blockRect.top - shellRect.top + blockRect.height / 2 +} + +export function scrollPreviewToSourceLine( + scrollEl: HTMLElement, + article: HTMLElement, + line: number, + behavior: ScrollBehavior = 'smooth', + totalSourceLines?: number +): HTMLElement | null { + const total = totalSourceLines ?? article.querySelectorAll('[data-source-line]').length + const shell = article.closest('.preview-shell') ?? article.parentElement + const anchor = findPreviewAnchorForLine(article, line) + if (!anchor && !shell) return null + + const scrollRect = scrollEl.getBoundingClientRect() + let top: number + if (anchor) { + const anchorRect = anchor.getBoundingClientRect() + top = anchorRect.top - scrollRect.top + scrollEl.scrollTop - 20 + anchor.classList.add('preview-anchor-flash') + window.setTimeout(() => anchor.classList.remove('preview-anchor-flash'), 1600) + } else if (shell instanceof HTMLElement) { + const center = previewLineCenterInShell(article, shell, line, total) + top = (center ?? 0) - 40 + } else { + return null + } + scrollEl.scrollTo({ top: Math.max(0, top), behavior }) + return anchor +} + +/** @deprecated 使用 previewLineCenterInShell */ +export function anchorCenterInShell(anchor: HTMLElement, shell: HTMLElement): number { + const shellRect = shell.getBoundingClientRect() + const r = anchor.getBoundingClientRect() + return r.top - shellRect.top + r.height / 2 +} diff --git a/src/outlineTree.ts b/src/outlineTree.ts new file mode 100644 index 0000000..69f8a1d --- /dev/null +++ b/src/outlineTree.ts @@ -0,0 +1,39 @@ +import type { OutlineItem } from './markdown' + +export type OutlineTreeNode = OutlineItem & { + id: string + children: OutlineTreeNode[] +} + +export function buildOutlineTree(items: OutlineItem[]): OutlineTreeNode[] { + const root: OutlineTreeNode[] = [] + const stack: OutlineTreeNode[] = [] + for (const item of items) { + const node: OutlineTreeNode = { + ...item, + id: `o-${item.line}`, + children: [] + } + while (stack.length > 0 && stack[stack.length - 1]!.level >= node.level) { + stack.pop() + } + if (stack.length === 0) root.push(node) + else stack[stack.length - 1]!.children.push(node) + stack.push(node) + } + return root +} + +export function collectBranchIds(nodes: OutlineTreeNode[]): string[] { + const ids: string[] = [] + const walk = (list: OutlineTreeNode[]): void => { + for (const n of list) { + if (n.children.length > 0) { + ids.push(n.id) + walk(n.children) + } + } + } + walk(nodes) + return ids +} diff --git a/src/pathLite.ts b/src/pathLite.ts new file mode 100644 index 0000000..ce4880a --- /dev/null +++ b/src/pathLite.ts @@ -0,0 +1,177 @@ +/** 轻量路径工具,供渲染进程 Markdown 使用(避免依赖 node:path 打包问题)。 */ + +/** 是否为 Windows 磁盘绝对路径(如 `C:/Users/...`)。 */ +export function isAbsDiskPath(p: string): boolean { + return /^[A-Za-z]:\//.test(p.replace(/\\/g, '/')) +} + +/** 归一化为正斜杠绝对路径(保留盘符)。 */ +export function normalizeAbsPath(p: string): string { + let n = p.trim().replace(/\\/g, '/') + if (!n) return n + const win = n.match(/^([A-Za-z]:)\/(.*)$/) + if (win) { + const drive = win[1]!.toUpperCase() + const tail = normalizeDotSegments(win[2] ?? '') + return tail ? `${drive}/${tail}` : `${drive}/` + } + if (n.startsWith('/')) { + const tail = normalizeDotSegments(n.slice(1)) + return tail ? `/${tail}` : '/' + } + return normalizeDotSegments(n) +} + +export function joinAbsPath(dir: string, rel: string): string { + const base = normalizeAbsPath(dir).replace(/\/+$/, '') + const tail = rel.replace(/^[/\\]+/, '') + return tail ? normalizeAbsPath(`${base}/${tail}`) : base +} + +export function dirnamePath(filePath: string): string { + const n = filePath.replace(/\\/g, '/') + const i = n.lastIndexOf('/') + if (i < 0) return '.' + const d = n.slice(0, i) + if (/^[A-Za-z]:$/.test(d)) return `${d}/` + return d || '/' +} + +/** 去掉 . /.. 的段落归一化(不含盘符)。 */ +export function normalizeDotSegments(pathWithoutDrive: string): string { + const segs = pathWithoutDrive.split('/').filter((s) => s && s !== '.') + const st: string[] = [] + for (const s of segs) { + if (s === '..') st.pop() + else st.push(s) + } + return st.join('/') +} + +/** 将相对 href 解析为绝对本地路径(正斜杠)。 */ +export function resolvePathFromBaseFile(baseFilePath: string, href: string): string { + let h = href.trim().replace(/\\/g, '/') + const q = h.indexOf('?') + if (q >= 0) h = h.slice(0, q) + const hash = h.indexOf('#') + if (hash >= 0) h = h.slice(0, hash) + + const baseNorm = baseFilePath.replace(/\\/g, '/') + const winBase = baseNorm.match(/^([A-Za-z]:)\/(.*)$/) + + if (/^[A-Za-z]:\//i.test(h)) { + const m = h.match(/^([A-Za-z]:\/)(.*)$/)! + return `${m[1]}${normalizeDotSegments(m[2] ?? '')}` + } + + if (h.startsWith('/')) { + if (winBase) { + return `${winBase[1]}/${normalizeDotSegments(h.slice(1))}` + } + return `/${normalizeDotSegments(h.slice(1))}` + } + + const dir = dirnamePath(baseNorm) + const joined = dir.endsWith('/') ? `${dir}${h}` : `${dir}/${h}` + + if (winBase) { + const m = joined.match(/^([A-Za-z]:\/)(.*)$/) + if (m) return `${m[1]}${normalizeDotSegments(m[2] ?? '')}` + } + if (joined.startsWith('/')) return `/${normalizeDotSegments(joined.slice(1))}` + return normalizeDotSegments(joined) +} + +/** VFS 根路径,如 `/项目名`。 */ +export function vfsRootFromPath(filePath: string): string { + const parts = filePath.replace(/\\/g, '/').split('/').filter(Boolean) + return parts.length > 0 ? `/${parts[0]}` : '/' +} + +export function joinVfsPath(dir: string, rel: string): string { + const base = dir.replace(/\\/g, '/').replace(/\/+$/, '') + const tail = rel.replace(/^\/+/, '') + return tail ? `${base}/${tail}` : base +} + +/** 归一化 VFS 路径(`/a/../b` → `/b`)。 */ +export function normalizeVfsPath(p: string): string { + const parts = p.replace(/\\/g, '/').split('/').filter(Boolean) + const st: string[] = [] + for (const part of parts) { + if (part === '..') { + if (st.length) st.pop() + } else if (part !== '.') { + st.push(part) + } + } + return st.length ? `/${st.join('/')}` : '/' +} + +/** + * 将 Markdown 内链解析为 VFS 路径(Web 虚拟路径,非磁盘绝对路径)。 + * `/plans/foo.md` 相对当前已打开项目根,而非系统根。 + */ +export function resolveVfsPath(baseFilePath: string, href: string): string { + let h = href.trim().replace(/\\/g, '/') + const q = h.indexOf('?') + if (q >= 0) h = h.slice(0, q) + const hash = h.indexOf('#') + if (hash >= 0) h = h.slice(0, hash) + + const base = baseFilePath.replace(/\\/g, '/') + + if (/^https?:\/\//i.test(h) || /^mailto:/i.test(h) || /^tel:/i.test(h)) return h + if (/^(blob:|data:|file:)/i.test(h)) return h + + if (/^[A-Za-z]:\//i.test(h)) return normalizeVfsPath(h) + + if (h.startsWith('/')) { + const root = vfsRootFromPath(base) + return normalizeVfsPath(joinVfsPath(root, h.slice(1))) + } + + const dir = dirnamePath(base) + return normalizeVfsPath(joinVfsPath(dir, h)) +} + +/** `file:///C:/path/to.png` → `C:/path/to.png` */ +export function fileUrlToPath(url: string): string { + let u = url.trim() + if (!/^file:/i.test(u)) return u + u = u.replace(/^file:\/+/, '') + if (/^\/[A-Za-z]:/.test(u)) u = u.slice(1) + try { + return decodeURIComponent(u) + } catch { + return u + } +} + +export function pathBasename(p: string): string { + const n = p.replace(/\\/g, '/').replace(/\/+$/, '') + const i = n.lastIndexOf('/') + return i >= 0 ? n.slice(i + 1) : n +} + +/** Windows 下路径比较(忽略大小写)。 */ +export function pathsEqual(a: string, b: string): boolean { + const na = normalizeAbsPath(a) + const nb = normalizeAbsPath(b) + if (na === nb) return true + if (isAbsDiskPath(na) && isAbsDiskPath(nb)) { + return na.toLowerCase() === nb.toLowerCase() + } + return false +} + +/** 绝对路径 → file:// URL(Windows / Unix)。 */ +export function absolutePathToFileUrl(absPath: string): string { + const norm = absPath.replace(/\\/g, '/').trim() + if (!norm) return '' + if (/^[A-Za-z]:\//.test(norm)) { + return `file:///${encodeURI(norm)}` + } + const withLead = norm.startsWith('/') ? norm : `/${norm}` + return `file://${encodeURI(withLead)}` +} diff --git a/src/platform/browserApi.ts b/src/platform/browserApi.ts new file mode 100644 index 0000000..cf37386 --- /dev/null +++ b/src/platform/browserApi.ts @@ -0,0 +1,294 @@ +import type { MyReaderApi } from '../env' +import type { + ListDirResult, + OpenFileResult, + OpenFolderResult, + OpenImageResult, + SaveDialogResult +} from '../types/ipc-types' +import { + dirnamePath, + isAbsDiskPath, + joinAbsPath, + normalizeAbsPath, + resolvePathFromBaseFile +} from '../pathLite' +import { askDiskPath, rememberedAbsRoot } from './pathPrompt' +import { supportsFileSystemAccess, webVfs } from './webVfs' + +const MD_PICKER = { + description: 'Markdown', + accept: { 'text/markdown': ['.md', '.markdown', '.mdown', '.mkd'] } +} + +const IMAGE_PICKER = { + description: '图片', + accept: { + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif'] + } +} + +function pickFilesViaInput(accept: string, multiple: boolean): Promise { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + input.accept = accept + input.multiple = multiple + input.style.display = 'none' + input.onchange = () => { + resolve(input.files) + input.remove() + } + input.oncancel = () => { + resolve(null) + input.remove() + } + document.body.appendChild(input) + input.click() + }) +} + +const menuHandlers = new Set<(action: string) => void>() + +function emitMenu(action: string): void { + for (const h of menuHandlers) h(action) +} + +function installKeyboardShortcuts(): void { + window.addEventListener('keydown', (e) => { + if (!e.ctrlKey && !e.metaKey) return + const k = e.key.toLowerCase() + if (k === 'o' && !e.shiftKey) { + e.preventDefault() + emitMenu('file-open') + } else if (k === 's' && e.shiftKey) { + e.preventDefault() + emitMenu('file-save-as') + } else if (k === 's') { + e.preventDefault() + emitMenu('file-save') + } else if (k === 't') { + e.preventDefault() + emitMenu('tab-new') + } else if (k === 'w') { + e.preventDefault() + emitMenu('tab-close') + } else if (k === 'e') { + e.preventDefault() + emitMenu('toggle-read') + } else if (k === 'tab' && e.shiftKey) { + e.preventDefault() + emitMenu('tab-prev') + } else if (k === 'tab') { + e.preventDefault() + emitMenu('tab-next') + } + }) +} + +let closeHandler: (() => void) | null = null +let closeAllowed = false + +function installBeforeUnload(): void { + window.addEventListener('beforeunload', (e) => { + if (closeAllowed) return + if (!closeHandler) return + closeHandler() + e.preventDefault() + e.returnValue = '' + }) +} + +async function promptFileAbsPath(fileName: string, suggested?: string): Promise { + const def = webVfs.getDefaultDirectory() + const guess = + suggested ?? + (isAbsDiskPath(def) ? joinAbsPath(def, fileName) : rememberedAbsRoot(fileName) ?? `F:\\${fileName}`) + const raw = await askDiskPath({ + title: '请输入文件的本机完整路径', + hint: `浏览器无法自动读取磁盘路径。请填写「${fileName}」的绝对路径(例如 F:\\coding\\notes\\${fileName})。`, + defaultValue: guess, + folderName: fileName + }) + return raw ? normalizeAbsPath(raw) : null +} + +export function createBrowserApi(): MyReaderApi { + installKeyboardShortcuts() + installBeforeUnload() + + return { + openFileDialog: async (): Promise => { + try { + if (supportsFileSystemAccess() && window.showOpenFilePicker) { + const handles = await window.showOpenFilePicker({ + multiple: false, + types: [MD_PICKER] + }) + const h = handles[0] + if (!h) return { canceled: true } + const path = await webVfs.registerOpenFile(h, webVfs.virtualPathForPickedFile(h.name)) + const content = await h.getFile().then((f) => f.text()) + return { canceled: false, path, content } + } + const files = await pickFilesViaInput('.md,.markdown,.mdown,.mkd', false) + if (!files?.[0]) return { canceled: true } + const file = files[0] + const path = webVfs.registerMemoryFile(webVfs.virtualPathForPickedFile(file.name), file) + webVfs.setDefaultDirectory(dirnamePath(path)) + const content = await file.text() + return { canceled: false, path, content } + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return { canceled: true } + console.error('[MyReader] openFileDialog', e) + const msg = e instanceof Error ? e.message : String(e) + window.alert(`打开文件失败:${msg}`) + return { canceled: true } + } + }, + + /** Web 版已关闭侧栏文件夹浏览;保留接口以兼容类型。 */ + openFolderDialog: async (): Promise => { + return { canceled: true } + }, + + openImageDialog: async (): Promise => { + try { + if (supportsFileSystemAccess() && window.showOpenFilePicker) { + const handles = await window.showOpenFilePicker({ + multiple: false, + types: [IMAGE_PICKER] + }) + const h = handles[0] + if (!h) return { canceled: true } + const vPath = webVfs.virtualPathForPickedFile(h.name) + await webVfs.registerOpenFile(h, vPath) + const file = await h.getFile() + const url = URL.createObjectURL(file) + return { canceled: false, url, path: vPath } + } + const files = await pickFilesViaInput('image/*', false) + if (!files?.[0]) return { canceled: true } + const file = files[0] + const vPath = webVfs.registerMemoryFile(webVfs.virtualPathForPickedFile(file.name), file) + const url = URL.createObjectURL(file) + return { canceled: false, url, path: vPath } + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return { canceled: true } + console.error('[MyReader] openImageDialog', e) + const msg = e instanceof Error ? e.message : String(e) + window.alert(`选择图片失败:${msg}`) + return { canceled: true } + } + }, + + saveFileDialog: async (defaultPath?: string): Promise => { + const name = defaultPath ? defaultPath.split(/[/\\]/).pop() : '未命名.md' + try { + const h = await webVfs.pickSaveHandle(name || '未命名.md') + if (!h) return { canceled: true } + let absPath = defaultPath ? normalizeAbsPath(defaultPath) : null + if (isAbsDiskPath(absPath || '')) { + /* keep disk path */ + } else if (absPath?.startsWith('/__web__/')) { + absPath = webVfs.virtualPathForPickedFile(h.name) + } else if (!absPath) { + absPath = webVfs.virtualPathForPickedFile(h.name) + } else { + absPath = await promptFileAbsPath(h.name, defaultPath) + if (!absPath) return { canceled: true } + } + const path = await webVfs.registerOpenFile(h, absPath!) + return { canceled: false, path } + } catch (e) { + console.error('[MyReader] saveFileDialog', e) + return { canceled: true } + } + }, + + readFile: (filePath: string) => webVfs.readFile(filePath), + + writeFile: async (filePath: string, content: string) => { + if (webVfs.hasPath(filePath)) { + await webVfs.writeFile(filePath, content) + return + } + const h = await webVfs.pickSaveHandle(baseNameFromPath(filePath)) + if (!h) throw new Error('保存已取消') + const norm = normalizeAbsPath(filePath) + let absPath: string | null = null + if (isAbsDiskPath(norm)) { + absPath = await promptFileAbsPath(h.name, filePath) + } else if (norm.startsWith('/__web__/')) { + absPath = webVfs.virtualPathForPickedFile(h.name) + } else { + absPath = await promptFileAbsPath(h.name, filePath) + } + if (!absPath) throw new Error('保存已取消') + const path = await webVfs.registerOpenFile(h, absPath) + await webVfs.writeFile(path, content) + }, + + listDirectory: (dirPath: string): Promise => webVfs.listDirectory(dirPath), + + parentDirectory: (dirPath: string): Promise => + Promise.resolve(webVfs.parentDirectory(dirPath)), + + resolveRelativePath: async (baseFilePath: string, href: string): Promise => { + let raw = href.trim() + const hashIdx = raw.indexOf('#') + if (hashIdx >= 0) raw = raw.slice(0, hashIdx) + const queryIdx = raw.indexOf('?') + if (queryIdx >= 0) raw = raw.slice(0, queryIdx) + raw = raw.trim() + if (!raw) return '' + if (/^https?:\/\//i.test(raw) || /^mailto:/i.test(raw) || /^tel:/i.test(raw)) return raw + if (/^(blob:|data:)/i.test(raw)) return raw + try { + raw = decodeURIComponent(raw) + } catch { + /* keep */ + } + return resolvePathFromBaseFile(baseFilePath, raw) + }, + + openExternal: async (url: string) => { + const u = url.trim() + if (/^https?:\/\//i.test(u) || /^mailto:/i.test(u) || /^tel:/i.test(u)) { + window.open(u, '_blank', 'noopener,noreferrer') + } + }, + + defaultDirectory: async () => webVfs.getDefaultDirectory(), + + deleteFile: (filePath: string) => webVfs.deleteFile(filePath), + + renamePath: (oldPath: string, newName: string) => webVfs.renamePath(oldPath, newName), + + allowClose: () => { + closeAllowed = true + }, + + onRequestClose: (handler: () => void) => { + closeHandler = handler + return () => { + if (closeHandler === handler) closeHandler = null + } + }, + + onMenuAction: (handler: (action: string) => void) => { + menuHandlers.add(handler) + return () => menuHandlers.delete(handler) + } + } +} + +function baseNameFromPath(p: string): string { + const n = p.replace(/\\/g, '/') + const i = n.lastIndexOf('/') + return i >= 0 ? n.slice(i + 1) : n +} + +export function installBrowserApi(): void { + window.myreader = createBrowserApi() +} diff --git a/src/platform/pathPrompt.ts b/src/platform/pathPrompt.ts new file mode 100644 index 0000000..39fac9b --- /dev/null +++ b/src/platform/pathPrompt.ts @@ -0,0 +1,41 @@ +export type PathPromptRequest = { + title: string + hint: string + defaultValue: string + folderName?: string +} + +type PathPromptFn = (req: PathPromptRequest) => Promise + +let promptFn: PathPromptFn | null = null + +export function registerPathPrompt(fn: PathPromptFn | null): void { + promptFn = fn +} + +export async function askDiskPath(req: PathPromptRequest): Promise { + if (promptFn) return promptFn(req) + const v = window.prompt(`${req.title}\n\n${req.hint}`, req.defaultValue) + return v?.trim() ? v.trim() : null +} + +const ABS_ROOT_KEY = 'myreader-abs-root-map' + +export function rememberedAbsRoot(folderName: string): string | undefined { + try { + const map = JSON.parse(localStorage.getItem(ABS_ROOT_KEY) || '{}') as Record + return map[folderName] + } catch { + return undefined + } +} + +export function rememberAbsRoot(folderName: string, absPath: string): void { + try { + const map = JSON.parse(localStorage.getItem(ABS_ROOT_KEY) || '{}') as Record + map[folderName] = absPath + localStorage.setItem(ABS_ROOT_KEY, JSON.stringify(map)) + } catch { + /* ignore */ + } +} diff --git a/src/platform/webVfs.ts b/src/platform/webVfs.ts new file mode 100644 index 0000000..48f1bf7 --- /dev/null +++ b/src/platform/webVfs.ts @@ -0,0 +1,458 @@ +import { randomId } from '../randomId' +import type { DirEntry, ListDirResult } from '../types/ipc-types' +import { + dirnamePath, + fileUrlToPath, + isAbsDiskPath, + joinAbsPath, + normalizeAbsPath, + pathBasename, + pathsEqual, + resolvePathFromBaseFile +} from '../pathLite' + +const MD_EXT = new Set(['.md', '.markdown', '.mdown', '.mkd']) +const IMAGE_EXT = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.svg', + '.bmp', + '.ico', + '.avif' +]) + +function isMarkdownFile(name: string): boolean { + const lower = name.toLowerCase() + const dot = lower.lastIndexOf('.') + if (dot < 0) return false + return MD_EXT.has(lower.slice(dot)) +} + +function isImageFile(name: string): boolean { + const lower = name.toLowerCase() + const dot = lower.lastIndexOf('.') + if (dot < 0) return false + return IMAGE_EXT.has(lower.slice(dot)) +} + +function parentPath(p: string): string { + const n = normalizeAbsPath(p).replace(/\/+$/, '') + const i = n.lastIndexOf('/') + if (i <= 0) return '/' + const d = n.slice(0, i) + if (/^[A-Za-z]:$/.test(d)) return `${d}/` + return d || '/' +} + +function baseName(p: string): string { + const n = p.replace(/\\/g, '/').replace(/\/+$/, '') + const i = n.lastIndexOf('/') + return i >= 0 ? n.slice(i + 1) : n +} + +type DirEntries = FileSystemDirectoryHandle & { + entries: () => AsyncIterableIterator<[string, FileSystemHandle]> +} + +async function* iterateDir( + dir: FileSystemDirectoryHandle +): AsyncGenerator<[string, FileSystemHandle]> { + const d = dir as DirEntries + for await (const entry of d.entries()) { + yield entry + } +} + +/** 浏览器端:File System Access 句柄 + 本机绝对路径(用户确认)映射。 */ +class WebVfs { + private dirHandles = new Map() + private fileHandles = new Map() + private memoryFiles = new Map() + private blobUrlByPath = new Map() + private defaultDir = '' + + getDefaultDirectory(): string { + return this.defaultDir + } + + setDefaultDirectory(p: string): void { + this.defaultDir = normalizeAbsPath(p) + } + + hasPath(path: string): boolean { + const n = normalizeAbsPath(path) + return this.fileHandles.has(n) || this.memoryFiles.has(n) || this.dirHandles.has(n) + } + + /** 仅通过 打开、无 FileHandle,无法写回用户磁盘原文件。 */ + isMemoryOnlyPath(path: string): boolean { + const n = normalizeAbsPath(path) + if (this.fileHandles.has(n)) return false + return this.memoryFiles.has(n) + } + + getCachedBlobUrlPublic(absPath: string): string | undefined { + return this.getCachedBlobUrl(absPath) + } + + listKnownImagePaths(): string[] { + return Array.from(this.blobUrlByPath.keys()) + } + + async registerDirectory(root: FileSystemDirectoryHandle, absRoot: string): Promise { + const rootPath = normalizeAbsPath(absRoot) + this.dirHandles.set(rootPath, root) + this.defaultDir = rootPath + await this.indexDirectory(root, rootPath, true) + return rootPath + } + + private async indexDirectory( + dir: FileSystemDirectoryHandle, + absPath: string, + deep: boolean + ): Promise { + for await (const [name, handle] of iterateDir(dir)) { + if (name.startsWith('.')) continue + const full = joinAbsPath(absPath, name) + if (handle.kind === 'directory') { + const sub = handle as FileSystemDirectoryHandle + this.dirHandles.set(full, sub) + if (deep) await this.indexDirectory(sub, full, true) + } else if (handle.kind === 'file') { + const fh = handle as FileSystemFileHandle + if (isMarkdownFile(name)) { + this.fileHandles.set(full, fh) + } + if (isImageFile(name)) { + void this.cacheBlobUrl(full, fh) + } + } + } + } + + private async cacheBlobUrl(absPath: string, handle: FileSystemFileHandle): Promise { + const key = normalizeAbsPath(absPath) + const prev = this.blobUrlByPath.get(key) + if (prev) return prev + const file = await handle.getFile() + const url = URL.createObjectURL(file) + this.blobUrlByPath.set(key, url) + return url + } + + /** 将图片 src(含 file://)解析为可显示的 blob URL。 */ + async resolveImageToBlobUrl(src: string, baseFilePath: string | null): Promise { + const s = src.trim() + if (!s) return null + if (/^(blob:|data:|https?:)/i.test(s)) return s + + const base = baseFilePath?.trim() ? normalizeAbsPath(baseFilePath) : null + const absPath = this.resolveImageAbsPath(s, base) + if (!absPath) return null + + const hit = await this.ensureBlobUrl(absPath) + if (hit) return hit + + if (base) { + const baseDir = dirnamePath(base) + const imgDir = dirnamePath(absPath) + if (pathsEqual(baseDir, imgDir)) { + const sibling = joinAbsPath(baseDir, pathBasename(absPath)) + if (!pathsEqual(sibling, absPath)) { + const alt = await this.ensureBlobUrl(sibling) + if (alt) return alt + } + } + } + + return this.ensureBlobUrlByBasename(pathBasename(absPath)) + } + + resolveImageAbsPath(src: string, baseFilePath: string | null): string | null { + const s = src.trim() + if (/^file:/i.test(s)) return normalizeAbsPath(fileUrlToPath(s)) + if (isAbsDiskPath(s)) return normalizeAbsPath(s) + if (baseFilePath) return normalizeAbsPath(resolvePathFromBaseFile(baseFilePath, s)) + return null + } + + private lookupFileHandle( + absPath: string + ): { key: string; handle: FileSystemFileHandle } | null { + const key = normalizeAbsPath(absPath) + const direct = this.fileHandles.get(key) + if (direct) return { key, handle: direct } + + for (const [k, h] of this.fileHandles) { + if (pathsEqual(k, key)) return { key: k, handle: h } + } + return null + } + + private lookupImageByBasename(fileName: string): { key: string; handle: FileSystemFileHandle } | null { + const want = fileName.toLowerCase() + let found: { key: string; handle: FileSystemFileHandle } | null = null + for (const [k, h] of this.fileHandles) { + if (!isImageFile(pathBasename(k))) continue + if (pathBasename(k).toLowerCase() !== want) continue + if (!found) found = { key: k, handle: h } + } + return found + } + + private async ensureBlobUrl(absPath: string): Promise { + const key = normalizeAbsPath(absPath) + const cached = this.getCachedBlobUrl(key) + if (cached) return cached + + const located = this.lookupFileHandle(key) + let handle = located?.handle + let storeKey = located?.key ?? key + + if (!handle) { + handle = (await this.ensureFileHandle(key)) ?? undefined + storeKey = key + } + if (!handle) return null + return this.cacheBlobUrl(storeKey, handle) + } + + private async ensureBlobUrlByBasename(fileName: string): Promise { + const located = this.lookupImageByBasename(fileName) + if (!located) return null + return this.ensureBlobUrl(located.key) + } + + private getCachedBlobUrl(absPath: string): string | undefined { + const key = normalizeAbsPath(absPath) + const direct = this.blobUrlByPath.get(key) + if (direct) return direct + for (const [k, url] of this.blobUrlByPath) { + if (pathsEqual(k, key)) return url + } + return undefined + } + + async registerOpenFile(handle: FileSystemFileHandle, absPath: string): Promise { + const normalized = normalizeAbsPath(absPath) + this.fileHandles.set(normalized, handle) + this.memoryFiles.delete(normalized) + this.defaultDir = dirnamePath(normalized) + return normalized + } + + /** 为「仅打开文件」生成稳定虚拟路径(无需用户填写磁盘绝对路径)。 */ + virtualPathForPickedFile(fileName: string): string { + const id = randomId() + const safe = + fileName.replace(/[/\\<>|:*?"\u0000-\u001f]/g, '_').trim() || 'note.md' + return normalizeAbsPath(`/__web__/open/${id}/${safe}`) + } + + registerMemoryFile(absPath: string, file: File): string { + const normalized = normalizeAbsPath(absPath) + this.memoryFiles.set(normalized, file) + return normalized + } + + async readFile(path: string): Promise { + const normalized = normalizeAbsPath(path) + const mem = this.memoryFiles.get(normalized) + if (mem) return mem.text() + let h = this.fileHandles.get(normalized) + if (!h) { + h = (await this.ensureFileHandle(normalized)) ?? undefined + } + if (!h) throw new Error(`文件不存在或未授权:${normalized}`) + const file = await h.getFile() + return file.text() + } + + async writeFile(path: string, content: string): Promise { + const normalized = normalizeAbsPath(path) + const mem = this.memoryFiles.get(normalized) + if (mem) { + this.memoryFiles.set(normalized, new File([content], mem.name, { type: 'text/markdown' })) + return + } + let h = this.fileHandles.get(normalized) + if (!h) { + h = await this.createFileAtParent(normalized) + } + const w = await h.createWritable() + await w.write(content) + await w.close() + } + + private async createFileAtParent(path: string): Promise { + const parent = parentPath(path) + const name = baseName(path) + const dir = this.dirHandles.get(parent) + if (!dir) throw new Error(`无法写入:父目录未打开 (${parent})`) + const h = await dir.getFileHandle(name, { create: true }) + this.fileHandles.set(path, h) + return h + } + + async listDirectory(dirPath: string): Promise { + const normalized = normalizeAbsPath(dirPath) + const dir = this.dirHandles.get(normalized) + if (!dir) { + return { entries: [], error: '未授权访问该目录(Web 版请使用工具栏「打开」选择文件)。' } + } + try { + const entries: DirEntry[] = [] + for await (const [name, handle] of iterateDir(dir)) { + if (name.startsWith('.')) continue + const full = joinAbsPath(normalized, name) + if (handle.kind === 'directory') { + this.dirHandles.set(full, handle as FileSystemDirectoryHandle) + entries.push({ name, path: full, kind: 'dir' }) + } else if (handle.kind === 'file' && isMarkdownFile(name)) { + this.fileHandles.set(full, handle as FileSystemFileHandle) + entries.push({ name, path: full, kind: 'file' }) + } + } + entries.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) + return { entries } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { entries: [], error: msg } + } + } + + parentDirectory(dirPath: string): string { + const n = normalizeAbsPath(dirPath) + if (!n) return n + if (isAbsDiskPath(n)) { + const p = parentPath(n) + return p + } + return parentPath(n) + } + + private findWorkspaceRoot(absPath: string): { rootPath: string; dir: FileSystemDirectoryHandle } | null { + const normalized = normalizeAbsPath(absPath) + let best = '' + let bestDir: FileSystemDirectoryHandle | null = null + for (const [rp, dir] of this.dirHandles) { + const root = normalizeAbsPath(rp) + if (normalized === root || normalized.startsWith(`${root}/`)) { + if (root.length > best.length) { + best = root + bestDir = dir + } + } + } + return bestDir ? { rootPath: best, dir: bestDir } : null + } + + async ensureFileHandle(absPath: string): Promise { + const normalized = normalizeAbsPath(absPath) + const direct = this.fileHandles.get(normalized) + if (direct) return direct + + const ws = this.findWorkspaceRoot(normalized) + if (!ws) return null + + const relParts = normalized.slice(ws.rootPath.length).split('/').filter(Boolean) + if (relParts.length === 0) return null + + try { + let cur = ws.dir + let curPath = ws.rootPath + for (let i = 0; i < relParts.length - 1; i++) { + const part = relParts[i]! + cur = await cur.getDirectoryHandle(part) + curPath = joinAbsPath(curPath, part) + this.dirHandles.set(curPath, cur) + } + const fileName = relParts[relParts.length - 1]! + const fh = await cur.getFileHandle(fileName) + this.fileHandles.set(normalized, fh) + if (isImageFile(fileName)) void this.cacheBlobUrl(normalized, fh) + return fh + } catch { + return null + } + } + + async deleteFile(path: string): Promise { + const normalized = normalizeAbsPath(path) + const parent = parentPath(normalized) + const name = baseName(normalized) + const dir = this.dirHandles.get(parent) + if (!dir) throw new Error('无法删除:父目录未找到') + await dir.removeEntry(name) + this.fileHandles.delete(normalized) + } + + async renamePath(oldPath: string, newName: string): Promise<{ newPath: string }> { + const oldNorm = normalizeAbsPath(oldPath) + const parent = parentPath(oldNorm) + const dir = this.dirHandles.get(parent) + if (!dir) throw new Error('无法重命名:父目录未找到') + const newPath = joinAbsPath(parent, newName.trim()) + const handle = this.fileHandles.get(oldNorm) ?? this.dirHandles.get(oldNorm) + if (!handle) throw new Error('无法重命名:条目未找到') + const mover = dir as FileSystemDirectoryHandle & { + move?: (item: FileSystemHandle, name: string) => Promise + } + if (typeof mover.move !== 'function') { + throw new Error('当前浏览器不支持重命名,请在资源管理器中手动操作') + } + await mover.move(handle, newName.trim()) + if (this.fileHandles.has(oldNorm)) { + this.fileHandles.delete(oldNorm) + if (handle.kind === 'file') this.fileHandles.set(newPath, handle as FileSystemFileHandle) + } else if (this.dirHandles.has(oldNorm)) { + this.dirHandles.delete(oldNorm) + if (handle.kind === 'directory') this.dirHandles.set(newPath, handle as FileSystemDirectoryHandle) + } + return { newPath } + } + + async pickSaveHandle( + suggestedName?: string + ): Promise { + if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) { + return null + } + if ('showSaveFilePicker' in window) { + try { + return await window.showSaveFilePicker!({ + suggestedName: suggestedName || '未命名.md', + types: [ + { + description: 'Markdown', + accept: { 'text/markdown': ['.md', '.markdown'] } + } + ] + }) + } catch { + return null + } + } + return null + } +} + +export const webVfs = new WebVfs() + +export function supportsFileSystemAccess(): boolean { + if (typeof window.showOpenFilePicker !== 'function') return false + // Chromium 上 API 可能「存在」但在非安全上下文调用会抛 SecurityError,必须走 降级。 + if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) return false + return true +} + +export function getCachedImageBlobUrl(absPath: string): string | undefined { + return webVfs.getCachedBlobUrlPublic(normalizeAbsPath(absPath)) +} diff --git a/src/randomId.ts b/src/randomId.ts new file mode 100644 index 0000000..89d5bc1 --- /dev/null +++ b/src/randomId.ts @@ -0,0 +1,18 @@ +/** + * 在 HTTP 非安全上下文中 `crypto.randomUUID` 可能不存在(如 http://域名)。 + * 提供降级实现,避免打开文件等流程报错。 + */ +export function randomId(): string { + const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined + if (c && typeof c.randomUUID === 'function') { + try { + return c.randomUUID() + } catch { + /* fall through */ + } + } + const t = Date.now().toString(36) + const r = Math.random().toString(36).slice(2, 12) + const r2 = Math.random().toString(36).slice(2, 8) + return `${t}-${r}${r2}` +} diff --git a/src/types/ipc-types.ts b/src/types/ipc-types.ts new file mode 100644 index 0000000..ea26bbb --- /dev/null +++ b/src/types/ipc-types.ts @@ -0,0 +1,32 @@ +export type DirEntry = { + name: string + path: string + kind: 'file' | 'dir' +} + +export type ListDirResult = { + entries: DirEntry[] + error?: string +} + +export type OpenFileResult = { + canceled: boolean + path?: string + content?: string +} + +export type SaveDialogResult = { + canceled: boolean + path?: string +} + +export type OpenFolderResult = { + canceled: boolean + path?: string +} + +export type OpenImageResult = { + canceled: boolean + url?: string + path?: string +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..93e40ea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..2ab3f37 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7a6d73a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +const PREFERRED_PORT = 1234 + +export default defineConfig({ + /** 部署在子路径 `https://域名/reader/` 时必须设置,否则 JS/CSS 会去请求 `/assets/` 根路径而 404 */ + base: '/reader/', + plugins: [react()], + server: { + port: PREFERRED_PORT, + strictPort: false, + open: true, + // 经域名 / 反代访问开发服务器时放行 Host(与 preview 一致) + allowedHosts: ['huangzjsecret.online', 'www.huangzjsecret.online', 'localhost', '127.0.0.1'] + }, + preview: { + port: PREFERRED_PORT, + strictPort: false, + // Nginx 会把 Host 原样转发,需与浏览器地址栏域名一致 + allowedHosts: ['huangzjsecret.online', 'www.huangzjsecret.online', 'localhost', '127.0.0.1'] + } +})