Compare commits
19 Commits
13271f11b6
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 54d9c45eab | |||
|
|
c300b4c8db | ||
|
|
492051cef0 | ||
|
|
1c0dd00088 | ||
|
|
0c09d94501 | ||
|
|
5f4a80fa01 | ||
|
|
fb51276bc5 | ||
|
|
b3c22fd597 | ||
|
|
967aada3ca | ||
| 03c6c15439 | |||
| 8194345667 | |||
| 499cec2273 | |||
|
|
cd64110bf8 | ||
|
|
eb27e4ca2d | ||
|
|
4fca4b7143 | ||
|
|
c602573d8e | ||
|
|
fb2315a2f3 | ||
|
|
527ca5ecc5 | ||
|
|
fadee6aacf |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ target/
|
|||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.idea/
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|||||||
53
docs/27-Git同步.md
Normal file
53
docs/27-Git同步.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# docs/27 — 工作区 Git 同步
|
||||||
|
|
||||||
|
需求(2026-11-14):左侧面板给工作区文件夹加 Git 同步:可设置远程
|
||||||
|
仓库、定时同步时间,手动/自动把文档变更同步到远程。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
- 只接管「工作区根目录本身就是仓库根」的文件夹:在父仓库的子目录里
|
||||||
|
不启用,避免误提交别的仓库(Rust 侧用 rev-parse --show-toplevel
|
||||||
|
与根目录比对)。
|
||||||
|
- 同步语义固定一条链:fetch → 提交本地更改(add -A)→ 变基到
|
||||||
|
origin/<分支> 之上 → push(无上游时 push -u)。先提交后变基,
|
||||||
|
避免脏工作区挡住 pull。变基冲突时自动 rebase --abort 还原到同步
|
||||||
|
前状态并报错停下,不留半成品、不强行覆盖,等用户手动处理。
|
||||||
|
- git 身份缺失(未配 user.email)时提交自动附带
|
||||||
|
-c user.name=new-markdown -c user.email=new-markdown@localhost,
|
||||||
|
不让同步卡在身份配置上。
|
||||||
|
- 凭据:走系统 git 与其凭据管理器;GIT_TERMINAL_PROMPT=0 禁止终端
|
||||||
|
交互提问,认证失败直接把错误显示在同步条上。Windows 下子进程加
|
||||||
|
CREATE_NO_WINDOW 防闪黑窗。
|
||||||
|
- 实现走 git CLI(Rust run_git 封装),不引 libgit2。
|
||||||
|
|
||||||
|
## 界面
|
||||||
|
|
||||||
|
- 侧栏文件模式顶部新增「Git 同步」条:状态一句(分支 · 待提交 N 处 ·
|
||||||
|
待推送/待拉取 · 已同步 / 未设置远程 / 未启用),右侧立即同步与
|
||||||
|
设置按钮;同步结果/错误显示在条下方一行,不弹窗打扰。
|
||||||
|
- 设置弹窗:远程仓库地址、(未初始化时)初始分支、定时同步间隔
|
||||||
|
(自绘下拉:关闭/5/15/30/每小时)。未初始化时保存= git init -b
|
||||||
|
分支 + 绑定 origin;已是仓库时只改 origin 与间隔。设置按工作区
|
||||||
|
存偏好 gitsync:<root>。
|
||||||
|
- 定时同步:打开工作区后 5 秒先跑一次,之后按间隔跑;同一工作区
|
||||||
|
多窗口时用 localStorage 租约(25 秒过期、10 秒续租,与草稿认领
|
||||||
|
同一思路)保证只有一个窗口执行,避免 index.lock 冲突;手动同步
|
||||||
|
不受租约限制。定时同步拉到新文件后自动刷新文件树。
|
||||||
|
|
||||||
|
## 代码
|
||||||
|
|
||||||
|
- Rust:commands.rs 新增 git_status / git_init_repo / git_set_remote
|
||||||
|
/ git_sync_now(28 条命令与 lib.rs 注册数对账一致)。
|
||||||
|
- 前端:services/gitSync.ts(设置解析、状态文案、提交信息、租约)、
|
||||||
|
components/GitSyncModal.tsx、Sidebar 的 GitSyncBar、App 状态与
|
||||||
|
定时器接线(设置弹窗纳入 anyModal)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- vitest 117+2(新增 tests/git-sync.test.tsx 9 例:设置解析、状态
|
||||||
|
文案、提交信息、租约互斥、同步条渲染/点击);tsc、构建通过。
|
||||||
|
- 同步条渲染截图核验(真实 CSS,两态)。
|
||||||
|
- 沙箱无 cargo,Rust 与真实 git 流程未编译/未实跑;真机验收重点:
|
||||||
|
①对 Gitea 仓库初始化并绑定;②改文档后手动同步,远程出现
|
||||||
|
「手动同步」提交;③另一处改同一文件后同步报冲突且本地不乱;
|
||||||
|
④开定时后到点自动同步;⑤双窗口开同一工作区时只有一个在跑。
|
||||||
65
docs/28-审计二轮.md
Normal file
65
docs/28-审计二轮.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# docs/28 — 审计二轮(聚焦 Git 同步,兼顾近期功能面)
|
||||||
|
|
||||||
|
时间:2026-11-14 晚。大王已真机测过大部分功能,本轮为代码审计:
|
||||||
|
逐行复核 fadee6a 的 Rust 命令与前端接线,并抽查 docs/20–26 的
|
||||||
|
关键不变量。先说结论:同步主链逻辑成立,发现 3 个中等问题已当场
|
||||||
|
修复,4 个低问题记录待办,1 个沿袭项(CSP)状态未变。
|
||||||
|
|
||||||
|
## 已修复
|
||||||
|
|
||||||
|
### A1(中)git 命令参数的选项注入面
|
||||||
|
分支名与远程地址来自用户输入,直接作 git 命令参数:以 `-` 开头
|
||||||
|
会被 git 解析为选项(如给 remote add 塞 `-f` 立即抓取任意地址)。
|
||||||
|
已在 Rust 入口加校验:validate_branch(简化 check-ref-format:
|
||||||
|
拒绝 `-`/`.` 开头、`..`、空白/控制字符与 `~^:?*[\]`、`.lock`
|
||||||
|
结尾、超长)与 validate_remote_url(拒绝 `-` 开头与空白/控制
|
||||||
|
字符);git_init_repo、git_set_remote 强制走校验。
|
||||||
|
|
||||||
|
### A2(中)全文件夹同步的知情与兜底
|
||||||
|
同步是 `add -A` 全文件夹提交:文件夹里的 .env、大二进制、系统
|
||||||
|
垃圾都会被提交并推送,原始实现无任何提示与防线。两处补强:
|
||||||
|
①init 时若无 .gitignore,种一份保守默认(.DS_Store、Thumbs.db、
|
||||||
|
desktop.ini、*.tmp、~$*、.env、.env.*);②设置弹窗文案明示同步
|
||||||
|
范围为文件夹全部文件(.gitignore 除外)与首次同步全量提交。
|
||||||
|
|
||||||
|
### A3(中)设置弹窗的陈旧状态误判
|
||||||
|
仓库状态(gitInfo)异步加载完成前点开设置,弹窗按「未启用」
|
||||||
|
渲染:远程地址为空、显示初始分支,保存会误走 init 流程并在
|
||||||
|
remote add 处报错。已给打开动作加守卫:状态未就绪时不开弹窗,
|
||||||
|
先刷新状态并在同步条给一句提示。
|
||||||
|
|
||||||
|
## 记录待办(本轮未改,附理由)
|
||||||
|
|
||||||
|
- B1(低)空仓库接入「已有内容」的远程:本地从未提交、远程分支
|
||||||
|
已存在时,变基未出生 HEAD 的行为随 git 版本而异;失败时报错
|
||||||
|
且可恢复(fetch 已完成,手动 git pull 一次即可)。不在同步链
|
||||||
|
里引入 reset --hard 类危险动作,维持现状。
|
||||||
|
- B2(低)git 子进程无超时:远程不可达(丢包型防火墙)时一次
|
||||||
|
同步可能挂数分钟,期间显示「正在同步…」且挡后续同步。后续
|
||||||
|
可给 run_git 加 spawn + 超时(建议 120 秒)。
|
||||||
|
- B3(低)设置保存与进行中的同步可交错:set-url 与 fetch 各用
|
||||||
|
当时地址,结果自洽、错误可见,不做互斥。
|
||||||
|
- B4(低)SSH 远程首次连接的主机密钥确认:无终端环境会直接
|
||||||
|
失败并显示错误,需先在终端连一次该主机。属预期行为。
|
||||||
|
|
||||||
|
## 沿袭项
|
||||||
|
|
||||||
|
- C1 CSP 仍为 null、Markdown 原始 HTML 的渲染面:docs/12 已
|
||||||
|
记录未动,本轮复核状态未变,建议阶段 7 发布前单独处理。
|
||||||
|
|
||||||
|
## 复核通过(无问题)
|
||||||
|
|
||||||
|
- 同步链顺序 fetch→提交→变基→push;脏工作区不挡 pull;冲突
|
||||||
|
rebase --abort 还原;身份缺失兜底 -c;无上游时 push -u。
|
||||||
|
- 父仓库子目录不接管(show-toplevel 比对);命令数与注册数
|
||||||
|
对账 28=28;Windows 子进程 CREATE_NO_WINDOW 防闪窗。
|
||||||
|
- 前端:同步重入守卫(ref)、定时租约互斥、拉取成功刷文件树、
|
||||||
|
工作区切换时状态复位与迟到响应丢弃、设置弹窗纳入 anyModal。
|
||||||
|
- 抽查:chunkLoader 追加有 isCancelled 守卫;全仓无 TODO/
|
||||||
|
FIXME/console.log 残留。
|
||||||
|
|
||||||
|
## 验证边界
|
||||||
|
|
||||||
|
- vitest 117+2 全过、tsc 与生产构建通过。
|
||||||
|
- Rust 校验逻辑沙箱无 cargo 不可编译/单测,靠逐行复核;其实效
|
||||||
|
待下次 Windows 构建时一并确认(校验为纯函数,无宏与平台 API)。
|
||||||
200
docs/29-全项目审计.md
Normal file
200
docs/29-全项目审计.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# docs/29 — 全项目审计(性能 + 逻辑,三线并行 + App 编排层自审)
|
||||||
|
|
||||||
|
时间:2026-11-14 晚。范围:src/editor/ 全部、EditorPane/SourceEditor、
|
||||||
|
src/services/、src/components/、src/hooks/、src/utils/、App.tsx 编排层、
|
||||||
|
src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据问题**
|
||||||
|
(已修)、中低问题一批(已修 20 余处),其余记录待办;三线均未再
|
||||||
|
发现新的高危。搜索结果上限(每文件 20 条/共 300 文件)顺带核实存在。
|
||||||
|
|
||||||
|
## 已修复
|
||||||
|
|
||||||
|
### 高危
|
||||||
|
|
||||||
|
- **H1 分片载入未完成时窗格卸载,部分文档被静默回写并截断**:
|
||||||
|
EditorPane 卸载 cleanup 无条件回写最终序列化,而实时回写路径有
|
||||||
|
muted(载入中)守卫、唯独卸载路径漏了。载入中被保活淘汰(打开
|
||||||
|
第 7 个标签)或关标签时,回写的是已追加的部分文本;
|
||||||
|
syncEditorContent 对干净标签会把 savedContent 一起对齐到部分
|
||||||
|
文本——不标脏、无提示,下一次保存即把截断内容写入磁盘。修复:
|
||||||
|
cleanup 在 muted 时跳过回写(状态里本就是磁盘完整原文);载入
|
||||||
|
失败路径已主动解除 muted 并如实回写,不受守卫影响。同处顺带修:
|
||||||
|
`editor.create()` 完成回调无取消守卫,卸载后仍会把已销毁编辑器
|
||||||
|
的 API 注册回 App → 补 cancelled 检查并销毁。回归测试见
|
||||||
|
tests/editor-pane-unload.test.tsx(对照组);中途卸载的交错在
|
||||||
|
jsdom 里无法自动化复现(React act 会抽干异步追加,三种卸载方式
|
||||||
|
实测均如此),该守卫靠代码复核保证:muted 仅在追加完成/失败的
|
||||||
|
收尾回调里解除。
|
||||||
|
|
||||||
|
### 性能(中)
|
||||||
|
|
||||||
|
- **P1 Sidebar 的 memo 被三个不稳定 props 击穿,击键时整棵文件树
|
||||||
|
重渲染**:gitSync 内联对象、onCollapseSidebar 内联回调、随正文
|
||||||
|
变化重建的 outline 数组。三处稳定化:gitSyncUi 用 useMemo、
|
||||||
|
collapseSidebar 用 useCallback、非大纲模式传模块级空数组常量。
|
||||||
|
两条审计线独立撞到同一问题,属实。
|
||||||
|
- **P2 光标行列每次选区变化从文档头全量遍历**:Milkdown
|
||||||
|
selectionUpdated 每事务同步分发,600K 文档光标在文末时单次上万
|
||||||
|
节点。改为 rAF 合并:闭包缓存最新一次计算、逐帧最多算一次
|
||||||
|
(行列与表格上下文同帧合并)。
|
||||||
|
- **P3 图片显示解析无缓存**:同一图片出现 N 次/切标签重挂/epoch
|
||||||
|
重建都重复读盘转 base64。App 层加(文档路径|src)→Promise 缓存,
|
||||||
|
上限 60 条 FIFO。
|
||||||
|
- **P4 表格工具栏滚动监听无节流**:每次 scroll 都量 DOM + setState。
|
||||||
|
改 rAF 合并 + 位置未变不 setState。打字机模式逐键重启平滑滚动
|
||||||
|
改 behavior:'auto' 防抖动。
|
||||||
|
- **P5 分片切分的行偏移 O(切点数×行数)**:改行偏移前缀表 O(1) 查表。
|
||||||
|
- **P6 导出 embed 图片串行逐张解析**:改 Promise.all 并发;
|
||||||
|
exportHtml 高亮器初始化失败后 rejected Promise 被永久缓存、
|
||||||
|
整会话高亮失效 → 失败时清缓存,下次导出重试。
|
||||||
|
- **P7(Rust)list_dir 排序比较器内每次比较分配两个小写字符串**:
|
||||||
|
改 sort_by_cached_key 预计算键。
|
||||||
|
|
||||||
|
### 逻辑/健壮性(中)
|
||||||
|
|
||||||
|
- **L1(Rust)资源复制/保存命令是同步命令且无大小上限**:
|
||||||
|
copy_to_assets 对任意文件整读(冻 UI 线程 + 内存整载),
|
||||||
|
save_bytes_to_assets 同样无上限。两命令改 async + spawn_blocking,
|
||||||
|
统一 32MB 上限(与 read_file_base64 对齐)。写入侧补 64MB 上限,
|
||||||
|
与读侧对称。
|
||||||
|
- **L2(Rust)工作区搜索跟随 junction/符号链接逃出工作区**:去重
|
||||||
|
只防环,指向外部的 junction 是「新目录」会被整扫、耗尽配额并
|
||||||
|
越界读文件。scan_dir 加 canonical 包含校验,越界即跳过。顺带:
|
||||||
|
元数据取不到时由 fail-open 改为跳过;隐藏文件与隐藏目录同口径
|
||||||
|
跳过。
|
||||||
|
- **L3(Rust)recovery 草稿表无任何上限**:单条可达数十 MB、条数
|
||||||
|
无限增长、启动时全量正文过 IPC。recovery_save 加单条 10MB 上限
|
||||||
|
(超限静默跳过,草稿是副本、.md 是正本,不报错刷屏)+ 只保留
|
||||||
|
最近 20 条;workspaces 表同样只留最近 20 条。
|
||||||
|
- **L4(Rust)启动期数据库失败直接 panic 且循环复现**:DB 损坏/
|
||||||
|
磁盘异常时用户连界面都进不去。改为:打开/建表失败时把坏库
|
||||||
|
(含 -wal/-shm)改名 `.broken-<时间戳>` 备份后新建空库继续
|
||||||
|
启动;正文在磁盘 .md 中不受影响。连接另设 WAL + busy_timeout
|
||||||
|
3000(多开写操作排队而非直接 SQLITE_BUSY)。
|
||||||
|
- **L5 路径比较未做大小写归一(Windows 语义)**:pathsEqual/
|
||||||
|
isPathInside 大小写敏感,盘符/目录大小写不一致时 DnD 守卫漏判、
|
||||||
|
活动文件高亮消失、恢复键/Git 设置键/最近列表键同根裂键。新增
|
||||||
|
pathCompareKey(Windows 风格路径折叠小写,POSIX 保持敏感),
|
||||||
|
pathsEqual/isPathInside/文件树高亮/recoveryKey/Git 设置键与
|
||||||
|
租约键/legacy recent 去重共用。注意:升级后旧恢复记录键与新键
|
||||||
|
不一致,旧草稿可能多提示一次、随后被 20 条修剪清掉,无数据
|
||||||
|
损失。顺带修 parentDirOf('C:/a.md') 返回盘符相对路径 'C:' →
|
||||||
|
返回 'C:/'。
|
||||||
|
- **L6 文件树与标签页右键菜单无视口夹取**:靠边弹出溢出屏幕、尾
|
||||||
|
部条目够不到(编辑区菜单已有夹取,这两处漏了)。抽共享
|
||||||
|
clampMenuPosition(utils/menuPos.ts),三处菜单共用。
|
||||||
|
- **L7 逻辑小项打包**:①工作区搜索无乱序保护,慢的旧请求会覆盖
|
||||||
|
新结果 → 序号守卫;②换工作区时文件树短暂显示旧目录(expanded
|
||||||
|
按绝对路径残留)→ FileTree 按 root 加 key 重挂;③TabBar 拖拽
|
||||||
|
dragover 以浏览器节律重复 dispatch 同一移动 → (dragId:下标)
|
||||||
|
去重;④Dropdown 给 useDismiss 传内联 onClose,打开期间每次渲染
|
||||||
|
重注册 5 个全局监听 → useCallback 稳定;⑤Git 租约 check-then-
|
||||||
|
write 双窗口同启可同时取得 → 写入后回读确认持有者;⑥
|
||||||
|
windowClaims 注册表坏条目(keys 非数组)可让启动扫描抛错 →
|
||||||
|
prune 时整条丢弃坏条目;⑦store 的 UI 偏好 Web 模式只写不读 →
|
||||||
|
loadUiPref Web 分支读 localStorage;⑧多行粘贴尾随换行多建一个
|
||||||
|
空段落 → split 后丢弃末尾空项。
|
||||||
|
|
||||||
|
### 本轮新功能面(Git 同步)复审补强
|
||||||
|
|
||||||
|
- validate_branch 补:尾随 `.`、开头 `/`、连续 `//`、`@{`;
|
||||||
|
validate_remote_url 补 ext::/fd:: 可执行 transport 黑名单
|
||||||
|
(fetch 时会执行命令,用户自填场景威胁弱,属防患)。两函数已
|
||||||
|
抽出在沙箱实跑断言(合法/非法各一组,全过)。
|
||||||
|
- git_init_repo:.gitignore 播种提前到绑远程之前,部分失败不再
|
||||||
|
留「有仓库无忽略规则」的半成品;已有 origin 改 set-url 不再
|
||||||
|
报错。默认 .gitignore 补 `.*.tmp-*`:原子写临时文件在崩溃
|
||||||
|
残留时原本会被 `git add -A` 提交推送。
|
||||||
|
- git_sync_now 游离 HEAD 时不再猜分支名 "main"(会把成果推错
|
||||||
|
地方),直接报错提示先切回分支。
|
||||||
|
|
||||||
|
### 一致性
|
||||||
|
|
||||||
|
- FindBar 与表格工具栏的原生 title 提示迁到自绘 Tooltip(跟随
|
||||||
|
主题,与大王既定偏好对齐;路径类完整信息 title 保留);FindBar
|
||||||
|
开关按钮补 aria-pressed。
|
||||||
|
- GitSyncModal 补 Esc/点背幕关闭(忙碌时禁用),与 Stage5 弹窗
|
||||||
|
范式对齐。
|
||||||
|
- extractOutline 围栏判定改按字符+长度跟踪:四反引号围栏内的
|
||||||
|
三反引号行不再误翻转状态(与分片切分的围栏跟踪口径一致)。
|
||||||
|
|
||||||
|
### 已实测澄清(非问题)
|
||||||
|
|
||||||
|
- **分片切分会把「列表内缩进围栏」误判为顶层切点**(审计推导项):
|
||||||
|
已构造 >60K 含列表内嵌围栏的文档实测——旧逻辑下分片序列化与
|
||||||
|
整篇**确实不等**,属实。修复:围栏作切点时只认顶格;回归测试
|
||||||
|
已加入 tests/chunk-load.test.ts(关掉修复即失败,判别有效)。
|
||||||
|
- **替换文本含换行符**:实测 replaceAll('line1\nline2') 序列化为
|
||||||
|
段落内软换行,不拆段、不丢内容。语义与「粘贴多行建多段」不
|
||||||
|
同,属可接受差异,维持现状。
|
||||||
|
|
||||||
|
## 记录待办(本轮未改,附理由)
|
||||||
|
|
||||||
|
- D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16
|
||||||
|
文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但
|
||||||
|
落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。
|
||||||
|
→ **已于当晚按用户拍板实现**:saveTab 在落盘前拦截,首次保存
|
||||||
|
弹 EncodingConvertModal 说明将转 UTF-8(按路径记已确认、本次
|
||||||
|
运行只提示一次);自动保存不弹窗也不静默转码,跳过该文档等
|
||||||
|
手动保存时提示;确认后以冻结快照完成落盘,标签 encoding 如实
|
||||||
|
更新为 UTF-8。弹窗渲染置于所有弹窗之后(避免被关窗确认框
|
||||||
|
盖住)。vitest 123+2(含弹窗契约测试)。真机验证点:拿一个
|
||||||
|
GBK 文档改字后 Ctrl+S,应先弹提示、确认后才落盘。
|
||||||
|
- D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与
|
||||||
|
顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。
|
||||||
|
- D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms
|
||||||
|
防抖等价物):加防抖要先理清源码模式下保存读的是哪份状态,
|
||||||
|
贸然加会存旧文本,单独立项处理。
|
||||||
|
- D4(低)查找索引每次 findAll 全量重建(600K 约 5MB 瞬时):
|
||||||
|
可按 doc 引用缓存,未改。
|
||||||
|
- D5(低)专注模式开关是模块级全局变量,多窗格/隐藏解析编辑器
|
||||||
|
共享:目前靠各窗格写同一 App 值维持一致,「按窗格模式」时
|
||||||
|
需改为注入式。
|
||||||
|
- D6(低)文件树刷新时全部展开层级同时重拉(IPC burst)、拖拽
|
||||||
|
悬停整树重渲染:TreeLevel 未 memo 化,属结构性优化,单独立项。
|
||||||
|
- D7(低,Rust)git_status 串行拉起 6–7 个 git 子进程(每次约
|
||||||
|
百毫秒级):可改 `git status --porcelain=v2 --branch` 一次拿
|
||||||
|
全,解析重写风险大于当前收益,未改。
|
||||||
|
- D8(低,Rust)rename_path:仅大小写不同的改名被 exists 预检
|
||||||
|
误判冲突;跨卷移动直接报错而非复制+删除。前端入口目前多在
|
||||||
|
同盘同树内,影响有限,未改。
|
||||||
|
- D9(低,Rust)schema 无版本号/迁移机制:今后给表加列时
|
||||||
|
CREATE TABLE IF NOT EXISTS 不会迁移老库(配合 L4 的坏库重建,
|
||||||
|
最坏情况是偏好重置、正文无损)。加列前须先落 user_version。
|
||||||
|
- 沿袭:docs/28 的 B1–B4(空仓变基、git 子进程无超时、设置与
|
||||||
|
同步交错、SSH 主机密钥)与 C1(CSP=null)状态未变。
|
||||||
|
|
||||||
|
## 复核通过(无问题,不展开)
|
||||||
|
|
||||||
|
- Rust:原子写主链(同目录临时文件 + sync_all + rename,失败
|
||||||
|
清理)、编码探测顺序与 BOM 处理、sanitize_file_name 对
|
||||||
|
Windows 非法字符/保留名的处理、SQL 全部参数化、搜索四重
|
||||||
|
上限、git 同步链错误路径(变基失败先 abort 再报错)。
|
||||||
|
- 前端:useDismiss/MenuBar 监听清理完整;windowClaims 心跳
|
||||||
|
10s/过期 45s 与 Git 租约 10s 续租/25s TTL 配比合理;
|
||||||
|
exportHtml 代码块替换的降序 splice 与路径重写逻辑无误;
|
||||||
|
SourceEditor replaceAll 倒序 changes 数组经 CM6 实测正确。
|
||||||
|
- App 编排层:外部修改检测基线在打开/保存/重载各点维护完整,
|
||||||
|
不会误报自身写入;本轮另给 4 秒轮询加了防重叠(上轮未完跳过
|
||||||
|
本轮)。自动保存与手动保存并发同标签经原子写 + 幂等成立,
|
||||||
|
安全。菜单定义随击键重建属小开销,可接受。
|
||||||
|
|
||||||
|
## 验证边界
|
||||||
|
|
||||||
|
- vitest 122+2(新增:EditorPane 卸载对照、列表内嵌围栏等价、
|
||||||
|
路径/大纲新例、recoveryKey 新契约),tsc 与生产构建通过。
|
||||||
|
- Rust:沙箱仍无法跑完整 Tauri 构建(缺 GTK/WebKit 系统库);
|
||||||
|
四个改动文件已过 rustfmt 语法解析,validate_branch/
|
||||||
|
validate_remote_url 已抽出实跑断言全过;命令数与注册数对账
|
||||||
|
28=28 不变(两个命令改 async 不影响注册)。async 化、WAL、
|
||||||
|
坏库重建、搜索包含校验的实效待 Windows 构建与真机确认。
|
||||||
|
- H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用
|
||||||
|
「打开 >60K 文档后立刻连开多个标签挤掉其窗格,再回看该文档
|
||||||
|
是否完整」一验。
|
||||||
|
- **勘误(2026-11-14 晚,Windows 构建回报)**:本轮 M1 与 docs/27
|
||||||
|
的 Git 命令共 6 处用了 `tokio::task::spawn_blocking`,但工程并
|
||||||
|
没有 tokio 直接依赖(Cargo.toml 只有 tauri 等 7 个),Windows
|
||||||
|
构建报 E0433。已全部改为工程既有写法的
|
||||||
|
`tauri::async_runtime::spawn_blocking`(read/write 命令一直在
|
||||||
|
用),并全量核对源码中所有外部 crate 前缀与 Cargo.toml 依赖
|
||||||
|
一致、无其他幻影 crate。教训:写 Rust 时新引入的 crate 路径
|
||||||
|
必须先对 Cargo.toml 存量,沙箱无法完整编译时尤其如此。
|
||||||
78
docs/30-S3图片上传.md
Normal file
78
docs/30-S3图片上传.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# docs/30 — S3 图片上传
|
||||||
|
|
||||||
|
需求(2026-11-15 用户提出):①设置里能配 S3 并有「自动上传图片」开关;
|
||||||
|
②未上传的图片右键要有「上传到 S3」;③整个文档要有批量检测未上传图片
|
||||||
|
并上传的按钮。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
- **协议层在 Rust**(`src-tauri/src/s3.rs`):AWS SigV4 签名 + PUT Object,
|
||||||
|
reqwest(blocking + rustls)。配置随每次调用从前端传入,Rust 不落盘
|
||||||
|
保存密钥。命令 2 个(总数 30 与 lib.rs 注册对账):`s3_upload_image`、
|
||||||
|
`s3_test_connection`(对桶发签名 HEAD,按 200/403/404 给可读结论)。
|
||||||
|
- **寻址风格**:Endpoint 留空 = AWS S3 虚拟主机风格
|
||||||
|
`https://{bucket}.s3.{region}.amazonaws.com/{key}`;填了自定义
|
||||||
|
Endpoint(MinIO / R2 / OSS 等 S3 兼容服务)走路径风格
|
||||||
|
`{endpoint}/{bucket}/{key}`。对象键 = 前缀 + 原文件名(同名覆盖,
|
||||||
|
重复上传幂等)。配了「自定义访问域名」时,回填文档的地址按
|
||||||
|
「域名/存储桶/前缀/文件名」拼——MinIO 等自建服务里 API 地址与对外
|
||||||
|
访问地址是两个域名、访问域名通常指向服务根,桶名必须出现在路径里
|
||||||
|
(2026-11-15 用户指正,原实现漏了桶名一段)。不填访问域名时回填的
|
||||||
|
就是上传请求本身的地址(路径风格已含桶名、AWS 虚拟主机风格桶名在
|
||||||
|
域名里)。
|
||||||
|
- **配置存储**:进 UiState.s3(本机偏好库 SQLite/localStorage),字段
|
||||||
|
endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload;
|
||||||
|
加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。
|
||||||
|
弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。
|
||||||
|
- **「未上传」判定**:本地文件引用(相对路径)或内嵌 base64 图片
|
||||||
|
(`data:image/...;base64,...`)都算未上传(`isUploadableImageSrc`);
|
||||||
|
http(s)/blob 不算。base64 形态来自未落盘文档的粘贴图与导入文档自带
|
||||||
|
图(2026-11-15 用户指正:初版把 data: 一律排除、检测不到)。base64
|
||||||
|
图没有文件可读,走单独的 `s3_upload_base64` 命令(命令总数 31):
|
||||||
|
前端剥前缀传纯 base64,Rust 解码后与文件图共用同一条 PUT 通路;
|
||||||
|
对象名按内容哈希生成(image-<FNV-1a>.<扩展名>),同图同名幂等。
|
||||||
|
上传成功后把编辑器里所有同 src 的图片节点
|
||||||
|
一次事务换成 S3 地址(`replaceImageSrc`,同图多处引用一并换),文档
|
||||||
|
随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉
|
||||||
|
离线打开时的图,且用户可能还要本地备份)。
|
||||||
|
|
||||||
|
## 三个入口
|
||||||
|
|
||||||
|
1. **自动上传**(开关在 S3 设置里):粘贴/拖入(handleImageFile 落盘后)
|
||||||
|
与「插入图片」(copyToAssets 后)触发后台上传+回换;粘贴建节点是
|
||||||
|
异步的,第一次回换扑空时 900ms 后重试一次;再扑空或上传失败都给
|
||||||
|
提示(flash),不静默、本地引用保留。
|
||||||
|
2. **右键单张**:编辑区右键命中 <img> 时经 `imageSrcForElement`
|
||||||
|
(posAtDOM 反查节点)拿到文档 src;本地图时菜单多一项「上传到 S3」。
|
||||||
|
未配置 S3 时点它先弹设置弹窗并提示。
|
||||||
|
3. **批量**:文件 →「上传文档图片到 S3…」+ 状态栏按钮(当前文档有未
|
||||||
|
上传本地图片时出现,带张数)。扫描走编辑器 `listImages()`(PM 文档
|
||||||
|
遍历、去重),逐张上传回换,进度弹窗实时显示 第 N/总 张与当前文件
|
||||||
|
名,结束汇总成功/失败明细。源码模式下不可用(菜单禁用、状态栏不
|
||||||
|
计数),提示切回即时渲染。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- **签名算法**:独立小 crate 里对照 AWS 官方文档示例(IAM ListUsers,
|
||||||
|
20150830T123600Z)——算出的 Signature 与官方值
|
||||||
|
`5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7`
|
||||||
|
逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。
|
||||||
|
- **base64 通路**:同一独立 crate 里实测 upload_base64:解码后的字节
|
||||||
|
与原文一致地 PUT 到模拟 S3、Content-Type 按扩展名、非法 base64 在
|
||||||
|
发请求前就报错。
|
||||||
|
- **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地
|
||||||
|
TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、
|
||||||
|
x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、
|
||||||
|
自定义访问域名回填、HEAD 桶测试连接、空配置校验,2 例全过。
|
||||||
|
- **前端**:vitest 133+2(新增 s3.test 路径/判定、editor-images 收集/
|
||||||
|
回换/元素反查、s3-modal 保存载荷、右键菜单图片项);tsc 与构建通过;
|
||||||
|
设置弹窗无头浏览器截图核对(七个字段、开关、三按钮齐全)。
|
||||||
|
- **Cargo.lock 已更新**(reqwest 0.12.28 等,本机 cargo fetch 完成)。
|
||||||
|
|
||||||
|
## 待真机验证(Windows)
|
||||||
|
|
||||||
|
- Rust 完整构建(新增 reqwest/sha2/hmac 依赖)与真实 S3/兼容服务联调:
|
||||||
|
填真实配置 → 测试连接 → 粘贴图片自动上传 → 右键单张 → 批量上传,
|
||||||
|
并在桶里核对对象与文档回填地址可公开访问(桶需允许公开读或配 CDN)。
|
||||||
|
- 注意:签名时钟用系统 UTC 时间,本机时钟偏差过大会被 S3 拒绝
|
||||||
|
(报错会带回 S3 的 Code/Message,按提示校时即可)。
|
||||||
97
docs/31-图片编辑体验.md
Normal file
97
docs/31-图片编辑体验.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# 31 图片编辑体验(选中 / 换行 / 大小 / 链接)
|
||||||
|
|
||||||
|
用户真机反馈三件事:①右键图片无法编辑大小;②右键图片点「超链接」是
|
||||||
|
空的;③图片无法选中、没法在图片后面输入文本(按回车只能在前面换行)。
|
||||||
|
|
||||||
|
## ③ 选中与换行(根因)
|
||||||
|
|
||||||
|
无头 Chrome 实测复现(真实鼠标点击):
|
||||||
|
|
||||||
|
- 点击图片本身能形成 NodeSelection、点图片右侧也能把光标落在图片后——
|
||||||
|
但选中态**没有任何视觉提示**(PM 给的 `ProseMirror-selectednode`
|
||||||
|
类没有样式),用户感知就是「选不中」。
|
||||||
|
- 真正的死结在 Enter:图片被选中时按 Enter,默认命令链走 splitBlock,
|
||||||
|
空段落插到图片**前面**、选区仍停在图片上;接着打字会把图片**替换掉**。
|
||||||
|
这就是「只能在前面的文本处换行」的由来。
|
||||||
|
|
||||||
|
修法(`createImageEnterPlugin`,plugins.ts):
|
||||||
|
|
||||||
|
- 插件 view 生命周期里给编辑器 DOM 挂**捕获阶段** keydown 监听(预置
|
||||||
|
Enter keymap 插件排在本插件之前,props.handleKeyDown 轮不到);
|
||||||
|
只接管「图片 NodeSelection + 无修饰 Enter」:在图片所在文本块之后
|
||||||
|
插入空段落并把光标移进去,其余 Enter 全部放行。
|
||||||
|
- 注意 `createParagraphNear` 用不上:它对行内节点直接返回 false
|
||||||
|
(只处理块级选区),故插入逻辑自己写(resolve 到文本块深度、
|
||||||
|
after(depth) 处插段落)。
|
||||||
|
- CSS 给 `img.ProseMirror-selectednode` 加 2px 主题色描边,选中可见。
|
||||||
|
|
||||||
|
实测(无头 Chrome,真实点击):点击→NodeSelection+描边;Enter→新段落
|
||||||
|
在图片之后、光标在其中;打字落在新段落、图片完好。
|
||||||
|
|
||||||
|
## ① 图片大小
|
||||||
|
|
||||||
|
Markdown 没有尺寸语法,通行写法是 HTML。方案(imageSchema.ts):
|
||||||
|
|
||||||
|
- 包裹 preset 的 imageSchema:节点加 `width` 属性(字符串,`300`=px、
|
||||||
|
`50%`=百分比)。
|
||||||
|
- 解析:remark 插件(条目形状必须是 `{ plugin, options }`,裸函数不
|
||||||
|
生效)把孤立的 `<img …>` mdast html 节点换成 image 节点,width 存
|
||||||
|
data;根级(块级)要包一层 paragraph,行内原地替换。属性值做一遍
|
||||||
|
实体解码(mdast 保留源码原文,如 `"`)。
|
||||||
|
- 序列化:有 width 时发 `<img src alt title width>` html 节点(属性
|
||||||
|
转义),无 width 时仍发标准 ``,普通文档零影响。
|
||||||
|
- NodeView 按 width 设置 img 的 CSS 宽度(纯数字补 px)。
|
||||||
|
- UI:右键图片 →「调整图片大小…」弹窗(数值 + 像素/百分比分段钮,
|
||||||
|
可恢复原始大小)。带尺寸的图片在源文件里就是 HTML 形式,弹窗里
|
||||||
|
有明示。
|
||||||
|
|
||||||
|
往返实测:`<img src="b.png" alt="乙" width="300">` 解析/序列化逐字
|
||||||
|
一致;普通图片、非 img 的 HTML 均不受影响(tests/image-size.test.ts)。
|
||||||
|
|
||||||
|
**拖拽缩放**(同日用户追问后补):NodeView 改为包装结构
|
||||||
|
`span.img-wrap > img + span.img-resize-handle`,图片选中时右下角出现
|
||||||
|
手柄,拖动实时预览(只改 CSS 宽度),松手才提交节点属性(px 值,
|
||||||
|
上限为段落宽度、下限 20px)。两个坑:
|
||||||
|
- PM 用 mouse 事件:手柄上的 mousedown/mouseup/click 要吞掉,否则
|
||||||
|
PM 会按点击位置捣乱;
|
||||||
|
- `setNodeMarkup` 提交后 PM 会按 DOM 选区回读、把 NodeSelection 塌
|
||||||
|
成文本光标(手柄随之消失、无法连续拖)——提交事务里必须同带
|
||||||
|
`setSelection(NodeSelection)` 把选区设回去;
|
||||||
|
`updateImageAtElement`(弹窗改尺寸/链接)同理保选区。
|
||||||
|
无头 Chrome 真实拖拽实测:240 → 拖 +80 得 width="320"、再拖 −30 得
|
||||||
|
"290",选区全程不丢。
|
||||||
|
|
||||||
|
## ② 图片超链接
|
||||||
|
|
||||||
|
原「超链接」是文本链接:对选区套 link 标记,预填只有 `https://`,
|
||||||
|
对着图片点开自然是「空的」。图片的链接在 Markdown 里是包裹式
|
||||||
|
`[](href)`——实测这在本编辑器里可往返(link 标记挂在
|
||||||
|
图片节点上,解析/序列化都保留)。
|
||||||
|
|
||||||
|
修法(第一版理解偏了):先做了「预填包裹链接」的弹窗,用户随即
|
||||||
|
指出「没有带出来这张图片的 url」——用户要的是**图片自己的地址**
|
||||||
|
出现在弹窗里。更正后的 `ImageLinkModal`:主字段「图片地址」打开
|
||||||
|
即带出该图的 src(本地相对路径或网络地址),可查看可改,提交后
|
||||||
|
patch 到节点;包裹链接降为第二字段「点击时打开的链接(可空)」,
|
||||||
|
提交时一条事务同时处理 src 与 link 标记(`updateImageAtElement`)。
|
||||||
|
文本选区的链接流程不变。
|
||||||
|
|
||||||
|
配套的编辑器接口(EditorPaneApi):`imageInfoForElement`(读
|
||||||
|
src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点
|
||||||
|
改属性,右键菜单打开时记住元素与标签 id)。
|
||||||
|
|
||||||
|
## 图片右键菜单精简(同日用户要求)
|
||||||
|
|
||||||
|
右键命中图片时换成专用小菜单,只留与图片有关的项:
|
||||||
|
超链接… / 调整图片大小… / 上传到 S3(未上传图才有)/ 分隔线 /
|
||||||
|
剪切 / 复制 / 粘贴。加粗、斜体、删除线、行内代码这些文本格式项,
|
||||||
|
以及全选、源码模式切换,对图片没有意义,一律不出现(文本右键菜单
|
||||||
|
不变)。配套:右键图片时先把该图片设为节点选区
|
||||||
|
(`selectImageAtElement`),描边与手柄随之出现,菜单里的剪切/复制
|
||||||
|
和随后的 Delete 都作用在这张图上,与常见编辑器一致。
|
||||||
|
|
||||||
|
## 待真机验
|
||||||
|
|
||||||
|
- WebView2 里点击图片的选中手感(本机为 Chromium 实测);
|
||||||
|
- 调整大小后图片在文档中的实际观感与保存后重开的还原;
|
||||||
|
- 图片在列表/引用/表格里时 Enter 新段落的位置是否符合预期。
|
||||||
48
docs/32-md文件关联.md
Normal file
48
docs/32-md文件关联.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# docs/32 .md 文件关联(默认打开方式)
|
||||||
|
|
||||||
|
2026-11-15 用户要求:「支持设置 md 格式的打开方式」。目标:在资源
|
||||||
|
管理器双击 .md 能用本程序打开,且程序内能发起设置。三条链路:
|
||||||
|
|
||||||
|
## 1. 安装器注册(tauri.conf.json)
|
||||||
|
|
||||||
|
`bundle.fileAssociations` 增加 md/markdown(name=NewMarkdown,
|
||||||
|
role=Editor)。NSIS/MSI 安装时写入关联注册信息,本程序出现在
|
||||||
|
「打开方式」候选里,用户在打开方式对话框勾「始终」或在系统设置
|
||||||
|
里选一次即设为默认。注意:需用新版安装包**重装一次**才生效。
|
||||||
|
|
||||||
|
## 2. 程序内发起设置(文件菜单)
|
||||||
|
|
||||||
|
「文件 → 设为 .md 默认打开方式…」(仅桌面模式):Rust 命令
|
||||||
|
`register_file_association` 用 reg.exe 写当前用户注册表(免管理员):
|
||||||
|
ProgID `NewMarkdown.md`(类型名、打开命令 `"<exe>" "%1"`、图标)、
|
||||||
|
.md/.markdown 的 OpenWithProgids、Applications 的 SupportedTypes 与
|
||||||
|
打开命令——便携版/未走安装器的场景也能注册,且指向当前 exe 路径。
|
||||||
|
写完后打开 `ms-settings:defaultapps` 并弹窗说明:**Windows 不允许
|
||||||
|
程序直接抢占默认**(UserChoice 带哈希校验),最后一步必须由用户
|
||||||
|
在设置里把 .md 选为 new-markdown,只需一次。
|
||||||
|
|
||||||
|
## 3. 双击真正打开文件
|
||||||
|
|
||||||
|
- 单实例插件(tauri-plugin-single-instance,须最先注册):程序已
|
||||||
|
运行时双击第二个文件不再起新进程,第二实例的参数经回调转入
|
||||||
|
待打开队列并发 `pending-open-files` 事件,同时聚焦主窗口。
|
||||||
|
- 冷启动:setup 里读 `std::env::args`,与第二实例共用纯函数
|
||||||
|
`md_files_from_args` 过滤(跳过开关项、只收存在的 .md/.markdown),
|
||||||
|
存入 `PendingOpen` 队列。
|
||||||
|
- 前端:新命令 `take_pending_open_files` 取走队列并逐个 openPath。
|
||||||
|
主窗口在启动流程末尾拉一次(冷启动),并监听事件随时拉(热转
|
||||||
|
发)。**只由 label=main 的窗口消费**:子窗口(?file= 打开的)跑
|
||||||
|
同一套 App,不拦会把文件抢到子窗口里。
|
||||||
|
|
||||||
|
命令总数 31 → 33。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 参数过滤纯函数抽入独立 crate 实测:开关项/.txt/不存在的文件被
|
||||||
|
排除,.md 与 .markdown 存在文件入选。
|
||||||
|
- rustfmt 语法解析通过;命令数对账 33=33;tsc 0;vitest 全过
|
||||||
|
(InfoModal 新增 1 例);生产构建通过。
|
||||||
|
- 沙箱验不了(待真机):Windows 完整构建(含单实例插件与
|
||||||
|
cfg(windows) 的 reg.exe 分支)、安装包关联注册实效、双击冷启动
|
||||||
|
打开、已运行双击第二个文件转发到主窗口、菜单项注册后在系统
|
||||||
|
设置里选默认全流程。
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "new-markdown",
|
"name": "new-markdown",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "new-markdown",
|
"name": "new-markdown",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-markdown": "6.5.2",
|
"@codemirror/lang-markdown": "6.5.2",
|
||||||
"@codemirror/language-data": "6.5.2",
|
"@codemirror/language-data": "6.5.2",
|
||||||
|
|||||||
@@ -47,5 +47,5 @@
|
|||||||
"typecheck": "tsc -b --pretty false"
|
"typecheck": "tsc -b --pretty false"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.1.0"
|
"version": "0.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
869
src-tauri/Cargo.lock
generated
869
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "new-markdown"
|
name = "new-markdown"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "new-markdown — Windows Markdown 所见即所得编辑器"
|
description = "new-markdown — Windows Markdown 所见即所得编辑器"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
@@ -14,8 +14,13 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
trash = "5"
|
trash = "5"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ pub async fn write_text_file(path: String, content: String, keep_bom: bool) -> R
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result<(), String> {
|
fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result<(), String> {
|
||||||
|
if content.len() > 64 * 1024 * 1024 {
|
||||||
|
return Err("文档过大(超过 64MB),无法保存".to_string());
|
||||||
|
}
|
||||||
let target = PathBuf::from(&path);
|
let target = PathBuf::from(&path);
|
||||||
let dir = target
|
let dir = target
|
||||||
.parent()
|
.parent()
|
||||||
@@ -129,6 +132,9 @@ fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 资源目录:<文档目录>/<文档主名>.assets/,不存在则创建。返回目录路径。
|
/// 资源目录:<文档目录>/<文档主名>.assets/,不存在则创建。返回目录路径。
|
||||||
|
/// 资源文件大小上限(与 read_file_base64 对齐)。
|
||||||
|
const MAX_ASSET_BYTES: u64 = 32 * 1024 * 1024;
|
||||||
|
|
||||||
fn assets_dir(doc_path: &str) -> Result<PathBuf, String> {
|
fn assets_dir(doc_path: &str) -> Result<PathBuf, String> {
|
||||||
let doc = PathBuf::from(doc_path);
|
let doc = PathBuf::from(doc_path);
|
||||||
let dir = doc
|
let dir = doc
|
||||||
@@ -248,34 +254,51 @@ fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, S
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 把外部文件复制进文档资源目录,返回相对文档的路径(正斜杠,可直接写进 Markdown)。
|
/// 把外部文件复制进文档资源目录,返回相对文档的路径(正斜杠,可直接写进 Markdown)。
|
||||||
|
/// 异步 + spawn_blocking 且带大小上限:原先是同步命令整读任意文件,
|
||||||
|
/// 大文件会冻 UI 线程且内存整载(docs/29)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
|
pub async fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
|
||||||
let src = PathBuf::from(&source);
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let name = sanitize_file_name(
|
let src = PathBuf::from(&source);
|
||||||
&src.file_name()
|
let name = sanitize_file_name(
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
&src.file_name()
|
||||||
.unwrap_or_else(|| "file".into()),
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
);
|
.unwrap_or_else(|| "file".into()),
|
||||||
let dir = assets_dir(&doc_path)?;
|
);
|
||||||
let data = fs::read(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
|
let dir = assets_dir(&doc_path)?;
|
||||||
let target = write_new_file(&dir, &name, &data)?;
|
let meta = fs::metadata(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
|
||||||
Ok(relative_assets_path(&doc_path, &target))
|
if meta.len() > MAX_ASSET_BYTES {
|
||||||
|
return Err("文件过大(超过 32MB),无法作为资源复制".to_string());
|
||||||
|
}
|
||||||
|
let data = fs::read(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
|
||||||
|
let target = write_new_file(&dir, &name, &data)?;
|
||||||
|
Ok(relative_assets_path(&doc_path, &target))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把 base64 内容(如粘贴的图片)写入文档资源目录,返回相对路径。
|
/// 把 base64 内容(如粘贴的图片)写入文档资源目录,返回相对路径。
|
||||||
/// 审计修复:原先用 Vec<u8> 传参,Tauri 按 JSON 数字数组序列化,5MB 图要传 500 万个数字;
|
/// 审计修复:原先用 Vec<u8> 传参,Tauri 按 JSON 数字数组序列化,5MB 图要传 500 万个数字;
|
||||||
/// 改传 base64 字符串,体积与解析开销都降一个量级。
|
/// 改传 base64 字符串,体积与解析开销都降一个量级。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_bytes_to_assets(
|
pub async fn save_bytes_to_assets(
|
||||||
doc_path: String,
|
doc_path: String,
|
||||||
file_name: String,
|
file_name: String,
|
||||||
base64: String,
|
base64: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let name = sanitize_file_name(&file_name);
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let bytes = base64_decode(&base64)?;
|
let name = sanitize_file_name(&file_name);
|
||||||
let dir = assets_dir(&doc_path)?;
|
let bytes = base64_decode(&base64)?;
|
||||||
let target = write_new_file(&dir, &name, &bytes)?;
|
if bytes.len() as u64 > MAX_ASSET_BYTES {
|
||||||
Ok(relative_assets_path(&doc_path, &target))
|
return Err("内容过大(超过 32MB),无法保存为资源文件".to_string());
|
||||||
|
}
|
||||||
|
let dir = assets_dir(&doc_path)?;
|
||||||
|
let target = write_new_file(&dir, &name, &bytes)?;
|
||||||
|
Ok(relative_assets_path(&doc_path, &target))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
|
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||||
@@ -361,11 +384,8 @@ pub fn list_dir(path: String) -> Result<Vec<DirEntry>, String> {
|
|||||||
is_dir,
|
is_dir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
entries.sort_by(|a, b| {
|
// 排序键预计算一次(原比较器每次比较都分配两个小写副本,大目录下浪费明显)
|
||||||
b.is_dir
|
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
|
||||||
.cmp(&a.is_dir)
|
|
||||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
||||||
});
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,3 +645,386 @@ pub async fn workspace_search(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Git 同步(工作区文件夹) ----------
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitStatusInfo {
|
||||||
|
pub is_repo: bool,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub remote_url: Option<String>,
|
||||||
|
pub dirty_count: u32,
|
||||||
|
pub ahead: u32,
|
||||||
|
pub behind: u32,
|
||||||
|
pub has_upstream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitSyncOutcome {
|
||||||
|
pub committed: bool,
|
||||||
|
pub pushed: bool,
|
||||||
|
pub pulled: bool,
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分支名校验(简化版 check-ref-format):用户输入会作为 git 命令参数,
|
||||||
|
/// 以 - 开头会被解析成选项(选项注入),其余非法形态 git 自己也会拒绝。
|
||||||
|
fn validate_branch(branch: &str) -> Result<String, String> {
|
||||||
|
let b = branch.trim();
|
||||||
|
if b.is_empty() {
|
||||||
|
return Ok("main".to_string());
|
||||||
|
}
|
||||||
|
let bad = b.len() > 100
|
||||||
|
|| b.starts_with('-')
|
||||||
|
|| b.starts_with('.')
|
||||||
|
|| b.starts_with('/')
|
||||||
|
|| b.ends_with('/')
|
||||||
|
|| b.ends_with('.')
|
||||||
|
|| b.ends_with(".lock")
|
||||||
|
|| b.contains("..")
|
||||||
|
|| b.contains("//")
|
||||||
|
|| b.contains("@{")
|
||||||
|
|| b.chars().any(|c| c.is_whitespace() || c.is_control() || "~^:?*[\\]".contains(c));
|
||||||
|
if bad {
|
||||||
|
return Err(format!("分支名不合法:{b}"));
|
||||||
|
}
|
||||||
|
Ok(b.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 远程地址校验:不得以 - 开头(选项注入)、不得含空白/控制字符。
|
||||||
|
fn validate_remote_url(url: &str) -> Result<String, String> {
|
||||||
|
let u = url.trim();
|
||||||
|
if u.is_empty() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
// ext::/fd:: 是可执行 transport(fetch 时会跑命令),一律拒绝
|
||||||
|
let lower = u.to_lowercase();
|
||||||
|
if u.len() > 2000
|
||||||
|
|| u.starts_with('-')
|
||||||
|
|| lower.starts_with("ext::")
|
||||||
|
|| lower.starts_with("fd::")
|
||||||
|
|| u.chars().any(|c| c.is_whitespace() || c.is_control())
|
||||||
|
{
|
||||||
|
return Err("远程仓库地址不合法".to_string());
|
||||||
|
}
|
||||||
|
Ok(u.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||||
|
let mut cmd = std::process::Command::new("git");
|
||||||
|
cmd.args(args)
|
||||||
|
.current_dir(dir)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0");
|
||||||
|
// GUI 进程调控制台程序会闪黑窗,Windows 下隐藏
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
cmd.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
let out = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("无法运行 git(请确认已安装 Git):{e}"))?;
|
||||||
|
if out.status.success() {
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
return Err(stderr);
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
return Err(stdout);
|
||||||
|
}
|
||||||
|
Err("git 命令执行失败".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_status_inner(root: &str) -> GitStatusInfo {
|
||||||
|
let empty = GitStatusInfo {
|
||||||
|
is_repo: false,
|
||||||
|
branch: None,
|
||||||
|
remote_url: None,
|
||||||
|
dirty_count: 0,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
has_upstream: false,
|
||||||
|
};
|
||||||
|
let dir = Path::new(root);
|
||||||
|
if run_git(dir, &["rev-parse", "--is-inside-work-tree"]).ok().as_deref() != Some("true") {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
// 工作区必须是仓库根:在父仓库的子目录里不接管,避免误提交别的仓库
|
||||||
|
if let Ok(top) = run_git(dir, &["rev-parse", "--show-toplevel"]) {
|
||||||
|
let norm = |p: &str| p.replace('\\', "/").trim_end_matches('/').to_lowercase();
|
||||||
|
if norm(&top) != norm(root) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
let branch = run_git(dir, &["branch", "--show-current"]).ok().filter(|b| !b.is_empty());
|
||||||
|
let remote_url = run_git(dir, &["remote", "get-url", "origin"]).ok().filter(|u| !u.is_empty());
|
||||||
|
let dirty_count = run_git(dir, &["status", "--porcelain"])
|
||||||
|
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count() as u32)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut ahead = 0u32;
|
||||||
|
let mut behind = 0u32;
|
||||||
|
let has_upstream = run_git(dir, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).is_ok();
|
||||||
|
if has_upstream {
|
||||||
|
if let Ok(counts) = run_git(dir, &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]) {
|
||||||
|
let mut it = counts.split_whitespace();
|
||||||
|
behind = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||||
|
ahead = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GitStatusInfo { is_repo: true, branch, remote_url, dirty_count, ahead, behind, has_upstream }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_status(root: String) -> GitStatusInfo {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || git_status_inner(&root))
|
||||||
|
.await
|
||||||
|
.unwrap_or(GitStatusInfo {
|
||||||
|
is_repo: false,
|
||||||
|
branch: None,
|
||||||
|
remote_url: None,
|
||||||
|
dirty_count: 0,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
has_upstream: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result<GitStatusInfo, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let dir = Path::new(&root);
|
||||||
|
let b = validate_branch(&branch)?;
|
||||||
|
let url = validate_remote_url(&remote_url)?;
|
||||||
|
run_git(dir, &["init", "-b", &b])?;
|
||||||
|
// 同步会提交文件夹内全部未忽略文件:先种一份保守的 .gitignore
|
||||||
|
// (系统垃圾、临时文件、.env),避免敏感/无用文件被推走;
|
||||||
|
// .*.tmp-* 是编辑器原子写的临时文件,崩溃残留不应被同步提交。
|
||||||
|
// 播种放在绑远程之前,任一步失败也不会留下「有仓库没忽略规则」的半成品。
|
||||||
|
let gitignore = dir.join(".gitignore");
|
||||||
|
if !gitignore.exists() {
|
||||||
|
let _ = std::fs::write(
|
||||||
|
&gitignore,
|
||||||
|
".DS_Store\nThumbs.db\ndesktop.ini\n*.tmp\n.*.tmp-*\n~$*\n.env\n.env.*\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !url.is_empty() {
|
||||||
|
let has_origin = run_git(dir, &["remote"])
|
||||||
|
.map(|r| r.lines().any(|l| l.trim() == "origin"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_origin {
|
||||||
|
run_git(dir, &["remote", "set-url", "origin", &url])?;
|
||||||
|
} else {
|
||||||
|
run_git(dir, &["remote", "add", "origin", &url])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(git_status_inner(&root))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatusInfo, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let dir = Path::new(&root);
|
||||||
|
let url_owned = validate_remote_url(&remote_url)?;
|
||||||
|
let url = url_owned.as_str();
|
||||||
|
let has_origin = run_git(dir, &["remote"]).map(|r| r.lines().any(|l| l.trim() == "origin")).unwrap_or(false);
|
||||||
|
if url.is_empty() {
|
||||||
|
if has_origin {
|
||||||
|
run_git(dir, &["remote", "remove", "origin"])?;
|
||||||
|
}
|
||||||
|
} else if has_origin {
|
||||||
|
run_git(dir, &["remote", "set-url", "origin", url])?;
|
||||||
|
} else {
|
||||||
|
run_git(dir, &["remote", "add", "origin", url])?;
|
||||||
|
}
|
||||||
|
Ok(git_status_inner(&root))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcome, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let dir = Path::new(&root);
|
||||||
|
let st = git_status_inner(&root);
|
||||||
|
if !st.is_repo {
|
||||||
|
return Err("该文件夹不是 Git 仓库".to_string());
|
||||||
|
}
|
||||||
|
let has_remote = st.remote_url.is_some();
|
||||||
|
if has_remote {
|
||||||
|
run_git(dir, &["fetch", "origin"]).map_err(|e| format!("拉取远程信息失败:{e}"))?;
|
||||||
|
}
|
||||||
|
// 先提交本地更改,再变基到远程之上,最后推送——避免脏工作区挡住 pull
|
||||||
|
let mut committed = false;
|
||||||
|
if st.dirty_count > 0 {
|
||||||
|
run_git(dir, &["add", "-A"])?;
|
||||||
|
let mut args: Vec<&str> = Vec::new();
|
||||||
|
let email_missing = run_git(dir, &["config", "user.email"]).map(|v| v.is_empty()).unwrap_or(true);
|
||||||
|
if email_missing {
|
||||||
|
args.extend(["-c", "user.name=new-markdown", "-c", "user.email=new-markdown@localhost"]);
|
||||||
|
}
|
||||||
|
args.extend(["commit", "-m", message.as_str()]);
|
||||||
|
run_git(dir, &args).map_err(|e| format!("提交失败:{e}"))?;
|
||||||
|
committed = true;
|
||||||
|
}
|
||||||
|
// 游离 HEAD 时 branch 为空:不猜分支名(猜 main 会把成果推错地方),直接报错
|
||||||
|
let branch = match st.branch.clone() {
|
||||||
|
Some(b) => b,
|
||||||
|
None => return Err("当前处于游离 HEAD 状态,请先切回分支再同步".to_string()),
|
||||||
|
};
|
||||||
|
let mut pulled = false;
|
||||||
|
if has_remote {
|
||||||
|
let remote_ref = format!("origin/{branch}");
|
||||||
|
let remote_exists = run_git(dir, &["rev-parse", "--verify", &remote_ref]).is_ok();
|
||||||
|
if remote_exists {
|
||||||
|
let before = run_git(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
|
||||||
|
if let Err(e) = run_git(dir, &["rebase", &remote_ref]) {
|
||||||
|
let _ = run_git(dir, &["rebase", "--abort"]);
|
||||||
|
if e.contains("CONFLICT") || e.contains("could not apply") || e.contains("Cannot rebase") {
|
||||||
|
return Err("同步冲突:本地与远程修改了相同内容,已取消本次同步,请在该文件夹手动处理冲突后重试。".to_string());
|
||||||
|
}
|
||||||
|
return Err(format!("合并远程更新失败:{e}"));
|
||||||
|
}
|
||||||
|
let after = run_git(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
|
||||||
|
pulled = before != after || st.behind > 0;
|
||||||
|
}
|
||||||
|
if st.has_upstream {
|
||||||
|
run_git(dir, &["push"]).map_err(|e| format!("推送失败:{e}"))?;
|
||||||
|
} else {
|
||||||
|
run_git(dir, &["push", "-u", "origin", &branch]).map_err(|e| format!("推送失败:{e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut parts: Vec<&str> = Vec::new();
|
||||||
|
if pulled {
|
||||||
|
parts.push("已拉取远程更新");
|
||||||
|
}
|
||||||
|
if committed {
|
||||||
|
parts.push("已提交本地更改");
|
||||||
|
}
|
||||||
|
if has_remote {
|
||||||
|
parts.push("已推送到远程");
|
||||||
|
}
|
||||||
|
let summary = if parts.is_empty() {
|
||||||
|
"已是最新,没有需要同步的更改".to_string()
|
||||||
|
} else {
|
||||||
|
parts.join(",")
|
||||||
|
};
|
||||||
|
Ok(GitSyncOutcome { committed, pushed: has_remote, pulled, summary })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————————————————— S3 图片上传(docs/30) —————————————————
|
||||||
|
// 配置随调用从前端传入(设置弹窗收集、存本机偏好),Rust 侧不保存密钥;
|
||||||
|
// 实现在 s3.rs(SigV4 签名已对照 AWS 官方示例向量验证)。
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn s3_upload_image(
|
||||||
|
config: crate::s3::S3Config,
|
||||||
|
file_path: String,
|
||||||
|
file_name: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || crate::s3::upload(&config, &file_path, &file_name))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("上传任务失败:{e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取走待打开文件队列(docs/32):启动参数/第二实例双击转来的 .md 文件。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn take_pending_open_files(state: tauri::State<'_, crate::PendingOpen>) -> Vec<String> {
|
||||||
|
match state.0.lock() {
|
||||||
|
Ok(mut queue) => std::mem::take(&mut *queue),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把本程序注册为 .md/.markdown 的候选打开方式(docs/32),并打开 Windows
|
||||||
|
/// 「默认应用」设置页让用户确认。Windows 不允许程序直接抢占默认(UserChoice
|
||||||
|
/// 带校验),应用能做的是注册候选 + 引导用户点一次。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn register_file_association() -> Result<String, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(register_file_association_impl)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("注册任务失败:{e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn register_file_association_impl() -> Result<String, String> {
|
||||||
|
Err("文件关联设置仅支持 Windows。".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn register_file_association_impl() -> Result<String, String> {
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::process::Command;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
let exe = std::env::current_exe().map_err(|e| format!("无法定位程序位置:{e}"))?;
|
||||||
|
let exe_s = exe.to_string_lossy().to_string();
|
||||||
|
let exe_name = exe
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "new-markdown.exe".into());
|
||||||
|
let prog_id = "NewMarkdown.md";
|
||||||
|
let open_cmd = format!("\"{exe_s}\" \"%1\"");
|
||||||
|
let mut failures: Vec<String> = Vec::new();
|
||||||
|
let mut reg = |args: &[&str]| {
|
||||||
|
match Command::new("reg.exe")
|
||||||
|
.args(args)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(st) if st.success() => {}
|
||||||
|
Ok(st) => failures.push(format!("reg {:?} 退出码 {}", args.get(1), st.code().unwrap_or(-1))),
|
||||||
|
Err(e) => failures.push(format!("reg 执行失败:{e}")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// ProgID:类型名、打开命令、图标
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}"), "/ve", "/d", "Markdown 文档", "/f"]);
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]);
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\DefaultIcon"), "/ve", "/d", &format!("\"{exe_s}\",0"), "/f"]);
|
||||||
|
for ext in [".md", ".markdown"] {
|
||||||
|
// 扩展名的候选列表 + 应用的 SupportedTypes(出现在「打开方式」里)
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\{ext}\OpenWithProgids"), "/v", prog_id, "/t", "REG_SZ", "/d", "", "/f"]);
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\SupportedTypes"), "/v", ext, "/t", "REG_SZ", "/d", "", "/f"]);
|
||||||
|
}
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]);
|
||||||
|
if !failures.is_empty() {
|
||||||
|
return Err(format!("注册表写入失败:{}", failures.join(";")));
|
||||||
|
}
|
||||||
|
// 打开系统「默认应用」设置,用户在那里把 .md 选成本程序(只需一次)
|
||||||
|
let _ = Command::new("explorer.exe")
|
||||||
|
.arg("ms-settings:defaultapps")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.spawn();
|
||||||
|
Ok("已把 new-markdown 注册为 .md / .markdown 的候选打开方式,并打开了 Windows「默认应用」设置。请在设置里找到 .md,把默认应用选为 new-markdown(Windows 要求手动确认一次)。".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn s3_upload_base64(
|
||||||
|
config: crate::s3::S3Config,
|
||||||
|
base64: String,
|
||||||
|
file_name: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || crate::s3::upload_base64(&config, &base64, &file_name))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("上传任务失败:{e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("连接测试失败:{e}"))?
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,92 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
|
mod s3;
|
||||||
mod search;
|
mod search;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。
|
/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。
|
||||||
pub struct AppDb(pub Mutex<rusqlite::Connection>);
|
pub struct AppDb(pub Mutex<rusqlite::Connection>);
|
||||||
|
|
||||||
|
/// 待打开文件队列(docs/32):启动参数与第二实例转发的 .md 文件先存这里,
|
||||||
|
/// 前端就绪后经 take_pending_open_files 命令取走打开。
|
||||||
|
pub struct PendingOpen(pub Mutex<Vec<String>>);
|
||||||
|
|
||||||
|
/// 从命令行参数里挑出要打开的 Markdown 文件:跳过开关项,只收扩展名
|
||||||
|
/// .md/.markdown 且确实存在的文件。
|
||||||
|
pub fn md_files_from_args<I: IntoIterator<Item = String>>(args: I) -> Vec<String> {
|
||||||
|
args.into_iter()
|
||||||
|
.filter(|a| !a.starts_with('-'))
|
||||||
|
.filter(|a| {
|
||||||
|
let lower = a.to_lowercase();
|
||||||
|
(lower.ends_with(".md") || lower.ends_with(".markdown"))
|
||||||
|
&& std::path::Path::new(a).is_file()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。
|
||||||
|
fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String> {
|
||||||
|
let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?;
|
||||||
|
let _ = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get::<_, String>(0));
|
||||||
|
conn.busy_timeout(std::time::Duration::from_millis(3000))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
storage::init_schema(&conn)?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
// 单实例必须最先注册:双击第二个 .md 时不再起新进程,把文件转给
|
||||||
|
// 已运行的窗口打开(docs/32)。
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||||
|
let files = md_files_from_args(argv);
|
||||||
|
if !files.is_empty() {
|
||||||
|
if let Some(state) = app.try_state::<PendingOpen>() {
|
||||||
|
if let Ok(mut queue) = state.0.lock() {
|
||||||
|
queue.extend(files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = app.emit("pending-open-files", ());
|
||||||
|
}
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
let _ = w.unminimize();
|
||||||
|
let _ = w.set_focus();
|
||||||
|
}
|
||||||
|
}))
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let data_dir = app.path().app_data_dir().expect("app data dir");
|
let data_dir = app.path().app_data_dir().expect("app data dir");
|
||||||
std::fs::create_dir_all(&data_dir).expect("create app data dir");
|
std::fs::create_dir_all(&data_dir).expect("create app data dir");
|
||||||
let conn = rusqlite::Connection::open(data_dir.join("new-markdown.db"))
|
let db_path = data_dir.join("new-markdown.db");
|
||||||
.expect("open app database");
|
let conn = match open_and_init(&db_path) {
|
||||||
storage::init_schema(&conn).expect("init app database schema");
|
Ok(c) => c,
|
||||||
|
Err(first_err) => {
|
||||||
|
// 数据库损坏/磁盘异常时不再启动即崩:把坏库改名备份后新建空库
|
||||||
|
// 继续启动。偏好/草稿会重置,但正文都在磁盘 .md 里,不受影响(docs/29)
|
||||||
|
eprintln!("app database unusable ({first_err}); backing it up and recreating");
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let _ = std::fs::rename(&db_path, data_dir.join(format!("new-markdown.db.broken-{ts}")));
|
||||||
|
let _ = std::fs::rename(
|
||||||
|
data_dir.join("new-markdown.db-wal"),
|
||||||
|
data_dir.join(format!("new-markdown.db-wal.broken-{ts}")),
|
||||||
|
);
|
||||||
|
let _ = std::fs::rename(
|
||||||
|
data_dir.join("new-markdown.db-shm"),
|
||||||
|
data_dir.join(format!("new-markdown.db-shm.broken-{ts}")),
|
||||||
|
);
|
||||||
|
open_and_init(&db_path).expect("open app database")
|
||||||
|
}
|
||||||
|
};
|
||||||
app.manage(AppDb(Mutex::new(conn)));
|
app.manage(AppDb(Mutex::new(conn)));
|
||||||
|
// 冷启动参数(双击 .md 打开本程序时,文件路径在这里)
|
||||||
|
let initial = md_files_from_args(std::env::args().skip(1));
|
||||||
|
app.manage(PendingOpen(Mutex::new(initial)));
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -32,6 +100,15 @@ pub fn run() {
|
|||||||
commands::create_dir,
|
commands::create_dir,
|
||||||
commands::rename_path,
|
commands::rename_path,
|
||||||
commands::reveal_in_explorer,
|
commands::reveal_in_explorer,
|
||||||
|
commands::git_status,
|
||||||
|
commands::git_init_repo,
|
||||||
|
commands::git_set_remote,
|
||||||
|
commands::git_sync_now,
|
||||||
|
commands::s3_upload_image,
|
||||||
|
commands::s3_upload_base64,
|
||||||
|
commands::s3_test_connection,
|
||||||
|
commands::take_pending_open_files,
|
||||||
|
commands::register_file_association,
|
||||||
commands::delete_to_trash,
|
commands::delete_to_trash,
|
||||||
commands::db_recent_list,
|
commands::db_recent_list,
|
||||||
commands::db_recent_add,
|
commands::db_recent_add,
|
||||||
|
|||||||
348
src-tauri/src/s3.rs
Normal file
348
src-tauri/src/s3.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//! S3 兼容对象存储上传(docs/30):AWS SigV4 签名 + PUT Object。
|
||||||
|
//! 配置由前端设置弹窗收集、随每次调用传入,Rust 侧不落盘保存密钥。
|
||||||
|
//! endpoint 为空 = AWS S3(虚拟主机风格);填了自定义 endpoint(MinIO /
|
||||||
|
//! R2 / OSS 等 S3 兼容服务)走路径风格 {endpoint}/{bucket}/{key}。
|
||||||
|
//! 签名实现已对照 AWS 官方文档示例向量验证(签名值逐字一致)。
|
||||||
|
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct S3Config {
|
||||||
|
/// 自定义服务端点(空 = AWS 官方),如 https://minio.example.com:9000
|
||||||
|
#[serde(default)]
|
||||||
|
pub endpoint: String,
|
||||||
|
pub region: String,
|
||||||
|
pub bucket: String,
|
||||||
|
pub access_key: String,
|
||||||
|
pub secret_key: String,
|
||||||
|
/// 对象键前缀(可空),如 notes/
|
||||||
|
#[serde(default)]
|
||||||
|
pub prefix: String,
|
||||||
|
/// 自定义访问域名(可空),如 https://files.example.com;填了则回填文档的
|
||||||
|
/// 地址按「域名/存储桶/对象键」拼(API 地址与访问地址分离的部署,如
|
||||||
|
/// MinIO,访问域名指向服务根、桶名在路径里);不填则用上传请求本身的地址
|
||||||
|
#[serde(default)]
|
||||||
|
pub public_base: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
fn validate(cfg: &S3Config) -> Result<(), String> {
|
||||||
|
if cfg.bucket.trim().is_empty() {
|
||||||
|
return Err("请先在 S3 设置里填写存储桶(Bucket)。".into());
|
||||||
|
}
|
||||||
|
if cfg.region.trim().is_empty() {
|
||||||
|
return Err("请先在 S3 设置里填写区域(Region)。".into());
|
||||||
|
}
|
||||||
|
if cfg.access_key.trim().is_empty() || cfg.secret_key.trim().is_empty() {
|
||||||
|
return Err("请先在 S3 设置里填写 Access Key 与 Secret Key。".into());
|
||||||
|
}
|
||||||
|
let ep = cfg.endpoint.trim();
|
||||||
|
if !ep.is_empty() && !ep.starts_with("http://") && !ep.starts_with("https://") {
|
||||||
|
return Err("服务端点需以 http:// 或 https:// 开头。".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对象键:前缀(归一为无前导 /、非空时以 / 结尾)+ 文件名。
|
||||||
|
fn object_key(cfg: &S3Config, file_name: &str) -> String {
|
||||||
|
let prefix = cfg.prefix.trim().trim_start_matches('/');
|
||||||
|
if prefix.is_empty() {
|
||||||
|
file_name.to_string()
|
||||||
|
} else if prefix.ends_with('/') {
|
||||||
|
format!("{prefix}{file_name}")
|
||||||
|
} else {
|
||||||
|
format!("{prefix}/{file_name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传目标:返回 (请求 URL, 签名用 host, canonical URI)。
|
||||||
|
fn target(cfg: &S3Config, key: &str) -> (String, String, String) {
|
||||||
|
let encoded_key = encode_uri_path(&format!("/{}", key.trim_start_matches('/')));
|
||||||
|
let endpoint = cfg.endpoint.trim().trim_end_matches('/');
|
||||||
|
if endpoint.is_empty() {
|
||||||
|
let host = format!("{}.s3.{}.amazonaws.com", cfg.bucket.trim(), cfg.region.trim());
|
||||||
|
(format!("https://{host}{encoded_key}"), host, encoded_key)
|
||||||
|
} else {
|
||||||
|
let after_scheme = endpoint.split("://").nth(1).unwrap_or(endpoint);
|
||||||
|
let host = after_scheme.split('/').next().unwrap_or(after_scheme).to_string();
|
||||||
|
let uri = format!("/{bucket}{encoded_key}", bucket = cfg.bucket.trim());
|
||||||
|
(format!("{endpoint}{uri}"), host, uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type_of(file_name: &str) -> &'static str {
|
||||||
|
let ext = file_name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||||
|
match ext.as_str() {
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"bmp" => "image/bmp",
|
||||||
|
"ico" => "image/x-icon",
|
||||||
|
"avif" => "image/avif",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client() -> Result<reqwest::blocking::Client, String> {
|
||||||
|
reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("初始化网络客户端失败:{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 S3 的 XML 错误体里粗提取 Code / Message,给用户一句能看懂的话。
|
||||||
|
fn s3_error(status: reqwest::StatusCode, body: &str) -> String {
|
||||||
|
let grab = |tag: &str| -> Option<String> {
|
||||||
|
let open = format!("<{tag}>");
|
||||||
|
let close = format!("</{tag}>");
|
||||||
|
let start = body.find(&open)? + open.len();
|
||||||
|
let end = body[start..].find(&close)? + start;
|
||||||
|
Some(body[start..end].trim().to_string())
|
||||||
|
};
|
||||||
|
match (grab("Code"), grab("Message")) {
|
||||||
|
(Some(code), Some(msg)) => format!("S3 返回 {status}({code}):{msg}"),
|
||||||
|
_ => format!("S3 返回 {status}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_amz_dates() -> (String, String) {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
amz_dates(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_request(
|
||||||
|
cfg: &S3Config,
|
||||||
|
method: &str,
|
||||||
|
host: &str,
|
||||||
|
canonical_uri: &str,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
payload_hash: &str,
|
||||||
|
) -> (String, String, String) {
|
||||||
|
let (amz_date, date_stamp) = now_amz_dates();
|
||||||
|
let mut extras: Vec<(&str, &str)> = vec![("x-amz-content-sha256", payload_hash)];
|
||||||
|
if let Some(ct) = content_type {
|
||||||
|
extras.push(("content-type", ct));
|
||||||
|
}
|
||||||
|
let auth = authorization_header(&SignParams {
|
||||||
|
method,
|
||||||
|
host,
|
||||||
|
canonical_uri,
|
||||||
|
canonical_query: "",
|
||||||
|
region: cfg.region.trim(),
|
||||||
|
service: "s3",
|
||||||
|
access_key: cfg.access_key.trim(),
|
||||||
|
secret_key: cfg.secret_key.trim(),
|
||||||
|
amz_date: &amz_date,
|
||||||
|
date_stamp: &date_stamp,
|
||||||
|
payload_hash,
|
||||||
|
extra_headers: &extras,
|
||||||
|
});
|
||||||
|
(auth, amz_date, date_stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传一个本地文件到 S3,成功返回文档应回填的公开访问地址。
|
||||||
|
pub fn upload(cfg: &S3Config, file_path: &str, file_name: &str) -> Result<String, String> {
|
||||||
|
validate(cfg)?;
|
||||||
|
let meta = std::fs::metadata(file_path)
|
||||||
|
.map_err(|e| format!("读取待上传图片失败({file_path}):{e}"))?;
|
||||||
|
if meta.len() > MAX_IMAGE_BYTES {
|
||||||
|
return Err("图片超过 64MB,未上传。".into());
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(file_path).map_err(|e| format!("读取待上传图片失败:{e}"))?;
|
||||||
|
upload_bytes(cfg, &bytes, file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传文档内嵌的 base64 图片(data:image/...;base64 数据,前端已剥掉前缀)。
|
||||||
|
/// 未落盘文档粘贴的图片只有这种形态,没有文件可读。
|
||||||
|
pub fn upload_base64(cfg: &S3Config, base64_str: &str, file_name: &str) -> Result<String, String> {
|
||||||
|
validate(cfg)?;
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(base64_str.trim())
|
||||||
|
.map_err(|e| format!("图片 base64 解码失败:{e}"))?;
|
||||||
|
if bytes.len() as u64 > MAX_IMAGE_BYTES {
|
||||||
|
return Err("图片超过 64MB,未上传。".into());
|
||||||
|
}
|
||||||
|
upload_bytes(cfg, &bytes, file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload_bytes(cfg: &S3Config, bytes: &[u8], file_name: &str) -> Result<String, String> {
|
||||||
|
let bytes = bytes.to_vec();
|
||||||
|
let key = object_key(cfg, file_name);
|
||||||
|
let (url, host, canonical_uri) = target(cfg, &key);
|
||||||
|
let payload_hash = sha256_hex(&bytes);
|
||||||
|
let content_type = content_type_of(file_name);
|
||||||
|
let (auth, amz_date, _) = signed_request(cfg, "PUT", &host, &canonical_uri, Some(content_type), &payload_hash);
|
||||||
|
let resp = client()?
|
||||||
|
.put(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.header("x-amz-date", amz_date)
|
||||||
|
.header("x-amz-content-sha256", &payload_hash)
|
||||||
|
.header("Content-Type", content_type)
|
||||||
|
.body(bytes)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("连接 S3 失败:{e}"))?;
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
let base = cfg.public_base.trim().trim_end_matches('/');
|
||||||
|
if base.is_empty() {
|
||||||
|
Ok(url)
|
||||||
|
} else {
|
||||||
|
// 自定义访问域名按「域名/存储桶/前缀/文件名」拼(docs/30):
|
||||||
|
// MinIO 等自建服务的 API 地址与对外访问地址是两个域名,访问域名
|
||||||
|
// 通常指向服务根,桶名必须出现在路径里,不能沿用虚拟主机风格的省略。
|
||||||
|
Ok(format!(
|
||||||
|
"{base}{}",
|
||||||
|
encode_uri_path(&format!("/{bucket}/{key}", bucket = cfg.bucket.trim()))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
Err(s3_error(status, &body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试连接:对存储桶发一个签名的 HEAD 请求,按状态码给出可读结论。
|
||||||
|
pub fn test_connection(cfg: &S3Config) -> Result<String, String> {
|
||||||
|
validate(cfg)?;
|
||||||
|
let (url, host, canonical_uri) = target(cfg, "");
|
||||||
|
// target 以空 key 生成的是「桶根」地址:虚拟主机风格即桶域名根,
|
||||||
|
// 路径风格即 {endpoint}/{bucket}/——HEAD 桶正合适
|
||||||
|
let payload_hash = sha256_hex(b"");
|
||||||
|
let (auth, amz_date, _) = signed_request(cfg, "HEAD", &host, &canonical_uri, None, &payload_hash);
|
||||||
|
let resp = client()?
|
||||||
|
.head(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.header("x-amz-date", amz_date)
|
||||||
|
.header("x-amz-content-sha256", &payload_hash)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("连接 S3 失败:{e}"))?;
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
Ok("连接成功:存储桶可访问,密钥有效。".into())
|
||||||
|
} else if status.as_u16() == 403 {
|
||||||
|
Err("服务器返回 403:密钥不正确,或该密钥无此存储桶的访问权限。".into())
|
||||||
|
} else if status.as_u16() == 404 {
|
||||||
|
Err("服务器返回 404:存储桶不存在(检查桶名与区域)。".into())
|
||||||
|
} else {
|
||||||
|
Err(format!("服务器返回 {status}。"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————————————————— SigV4 签名(已对照 AWS 官方示例向量验证) —————————————————
|
||||||
|
|
||||||
|
pub fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update(data);
|
||||||
|
hex(&h.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC 接受任意长度密钥");
|
||||||
|
mac.update(data);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 由 epoch 秒生成 SigV4 用的 UTC 时间串(amzDate 如 20150830T123600Z、dateStamp 如 20150830)。
|
||||||
|
pub fn amz_dates(epoch_secs: u64) -> (String, String) {
|
||||||
|
let days = (epoch_secs / 86_400) as i64;
|
||||||
|
let secs = epoch_secs % 86_400;
|
||||||
|
let (hh, mm, ss) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||||
|
// civil_from_days(Howard Hinnant)
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = (z - era * 146_097) as u64;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe as i64 + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let year = if m <= 2 { y + 1 } else { y };
|
||||||
|
(
|
||||||
|
format!("{year:04}{m:02}{d:02}T{hh:02}{mm:02}{ss:02}Z"),
|
||||||
|
format!("{year:04}{m:02}{d:02}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SignParams<'a> {
|
||||||
|
pub method: &'a str,
|
||||||
|
pub host: &'a str,
|
||||||
|
pub canonical_uri: &'a str,
|
||||||
|
pub canonical_query: &'a str,
|
||||||
|
pub region: &'a str,
|
||||||
|
pub service: &'a str,
|
||||||
|
pub access_key: &'a str,
|
||||||
|
pub secret_key: &'a str,
|
||||||
|
pub amz_date: &'a str,
|
||||||
|
pub date_stamp: &'a str,
|
||||||
|
pub payload_hash: &'a str,
|
||||||
|
/// 除 host / x-amz-date 外参与签名的请求头(名须小写)
|
||||||
|
pub extra_headers: &'a [(&'a str, &'a str)],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算 Authorization 头的完整值。
|
||||||
|
pub fn authorization_header(p: &SignParams) -> String {
|
||||||
|
let mut headers: Vec<(&str, &str)> = vec![("host", p.host), ("x-amz-date", p.amz_date)];
|
||||||
|
headers.extend_from_slice(p.extra_headers);
|
||||||
|
headers.sort_by(|a, b| a.0.cmp(b.0));
|
||||||
|
let canonical_headers: String = headers
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}:{}\n", v.trim()))
|
||||||
|
.collect();
|
||||||
|
let signed_headers: String = headers
|
||||||
|
.iter()
|
||||||
|
.map(|(k, _)| *k)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
let canonical_request = format!(
|
||||||
|
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||||
|
p.method, p.canonical_uri, p.canonical_query, canonical_headers, signed_headers, p.payload_hash
|
||||||
|
);
|
||||||
|
let scope = format!("{}/{}/{}/aws4_request", p.date_stamp, p.region, p.service);
|
||||||
|
let string_to_sign = format!(
|
||||||
|
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||||
|
p.amz_date,
|
||||||
|
scope,
|
||||||
|
sha256_hex(canonical_request.as_bytes())
|
||||||
|
);
|
||||||
|
let k_date = hmac(format!("AWS4{}", p.secret_key).as_bytes(), p.date_stamp.as_bytes());
|
||||||
|
let k_region = hmac(&k_date, p.region.as_bytes());
|
||||||
|
let k_service = hmac(&k_region, p.service.as_bytes());
|
||||||
|
let k_signing = hmac(&k_service, b"aws4_request");
|
||||||
|
let signature = hex(&hmac(&k_signing, string_to_sign.as_bytes()));
|
||||||
|
format!(
|
||||||
|
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||||
|
p.access_key, scope, signed_headers, signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URI 路径编码(SigV4 canonical URI 与公开地址共用):保留 / 与 RFC 3986 非保留字符。
|
||||||
|
pub fn encode_uri_path(path: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(path.len());
|
||||||
|
for b in path.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
|
||||||
|
out.push(b as char)
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ fn scan_dir(
|
|||||||
depth: usize,
|
depth: usize,
|
||||||
needle: &str,
|
needle: &str,
|
||||||
case_sensitive: bool,
|
case_sensitive: bool,
|
||||||
|
root_canonical: &Path,
|
||||||
state: &mut ScanState,
|
state: &mut ScanState,
|
||||||
out: &mut Vec<FileMatches>,
|
out: &mut Vec<FileMatches>,
|
||||||
) {
|
) {
|
||||||
@@ -81,6 +82,11 @@ fn scan_dir(
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
|
// 越界即跳过:junction/符号链接指向工作区外时,去重防环挡不住(对它来说是「新目录」),
|
||||||
|
// 会把扫描配额耗在外部文件系统上并越界读用户文件(docs/29)
|
||||||
|
if !canonical.starts_with(root_canonical) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !state.visited_dirs.insert(canonical) {
|
if !state.visited_dirs.insert(canonical) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,13 +106,20 @@ fn scan_dir(
|
|||||||
if name.starts_with('.') || name == "node_modules" {
|
if name.starts_with('.') || name == "node_modules" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
scan_dir(&path, depth + 1, needle, case_sensitive, state, out);
|
scan_dir(&path, depth + 1, needle, case_sensitive, root_canonical, state, out);
|
||||||
} else if is_searchable(&path) {
|
} else if is_searchable(&path) {
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
state.scanned_files += 1;
|
state.scanned_files += 1;
|
||||||
if let Ok(meta) = path.metadata() {
|
match path.metadata() {
|
||||||
if meta.len() > MAX_FILE_BYTES {
|
Ok(meta) => {
|
||||||
continue;
|
if meta.len() > MAX_FILE_BYTES {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// 元数据取不到时按跳过处理(原先是 fail-open,会整读任意大小文件)
|
||||||
|
Err(_) => continue,
|
||||||
}
|
}
|
||||||
let bytes = match fs::read(&path) {
|
let bytes = match fs::read(&path) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
@@ -147,6 +160,10 @@ pub fn search_workspace(root: &str, query: &str, case_sensitive: bool) -> Vec<Fi
|
|||||||
let needle = if case_sensitive { query.to_string() } else { query.to_lowercase() };
|
let needle = if case_sensitive { query.to_string() } else { query.to_lowercase() };
|
||||||
let mut state = ScanState { visited_dirs: HashSet::new(), scanned_files: 0 };
|
let mut state = ScanState { visited_dirs: HashSet::new(), scanned_files: 0 };
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
scan_dir(Path::new(root), 0, &needle, case_sensitive, &mut state, &mut out);
|
let root_canonical = match fs::canonicalize(root) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
scan_dir(Path::new(root), 0, &needle, case_sensitive, &root_canonical, &mut state, &mut out);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ pub fn workspace_add(conn: &Connection, path: &str, opened_at: i64) -> Result<()
|
|||||||
params![path, opened_at],
|
params![path, opened_at],
|
||||||
)
|
)
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
|
// 只留最近 20 条(与 recent 的修剪口径对齐,避免列表只增不减)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM workspaces WHERE path NOT IN (SELECT path FROM workspaces ORDER BY opened_at DESC LIMIT 20)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(err)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +173,12 @@ pub fn recovery_list(conn: &Connection) -> Result<Vec<RecoveryEntry>, String> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 恢复记录单条上限:草稿是正文副本(.md 才是唯一正本),超限直接跳过、
|
||||||
|
/// 不报错——否则自动保存每 6 秒都会撞一次错误提示。
|
||||||
|
const RECOVERY_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024;
|
||||||
|
/// 恢复记录最多保留最近更新的条数。
|
||||||
|
const RECOVERY_KEEP: usize = 20;
|
||||||
|
|
||||||
pub fn recovery_save(
|
pub fn recovery_save(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
doc_key: &str,
|
doc_key: &str,
|
||||||
@@ -175,6 +187,9 @@ pub fn recovery_save(
|
|||||||
content: &str,
|
content: &str,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
if content.len() > RECOVERY_MAX_CONTENT_BYTES {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO recovery (doc_key, path, title, content, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
|
"INSERT INTO recovery (doc_key, path, title, content, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
ON CONFLICT(doc_key) DO UPDATE SET path = excluded.path, title = excluded.title,
|
ON CONFLICT(doc_key) DO UPDATE SET path = excluded.path, title = excluded.title,
|
||||||
@@ -182,6 +197,11 @@ pub fn recovery_save(
|
|||||||
params![doc_key, path, title, content, updated_at],
|
params![doc_key, path, title, content, updated_at],
|
||||||
)
|
)
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM recovery WHERE doc_key NOT IN (SELECT doc_key FROM recovery ORDER BY updated_at DESC LIMIT ?1)",
|
||||||
|
params![RECOVERY_KEEP as i64],
|
||||||
|
)
|
||||||
|
.map_err(err)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}
|
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false},"wix":{"language":"zh-CN"}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"],"fileAssociations":[{"ext":["md","markdown"],"name":"NewMarkdown","description":"Markdown 文档","role":"Editor"}]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.2.0"}
|
||||||
534
src/App.tsx
534
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,21 @@
|
|||||||
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
||||||
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
||||||
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
|
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
|
||||||
import { useEffect, useRef, useState } from 'react'
|
// 展开层经 Portal 挂到 body 并用 fixed 定位(按触发钮 rect 计算、下方空间
|
||||||
|
// 不足时向上翻):弹窗等带 overflow 的容器里 absolute 定位会被滚动盒裁掉
|
||||||
|
// (Git 同步设置里实测被遮挡),Portal + fixed 才能真正逃出裁剪。
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useDismiss } from '../hooks/useDismiss'
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
|
|
||||||
export interface DropdownOption { value: string; label: string }
|
export interface DropdownOption { value: string; label: string }
|
||||||
|
|
||||||
|
interface MenuPos { left: number; top: number; width: number; maxHeight: number }
|
||||||
|
|
||||||
|
const ITEM_H = 31
|
||||||
|
const MENU_PAD = 8
|
||||||
|
const MAX_H = 264
|
||||||
|
|
||||||
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||||
value: string
|
value: string
|
||||||
options: DropdownOption[]
|
options: DropdownOption[]
|
||||||
@@ -14,12 +24,35 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
|||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [pos, setPos] = useState<MenuPos | null>(null)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
useDismiss(ref, open, () => setOpen(false))
|
const close = useCallback(() => setOpen(false), [])
|
||||||
|
const dismissRefs = useMemo(() => [ref, menuRef], [])
|
||||||
|
useDismiss(dismissRefs, open, close)
|
||||||
|
|
||||||
const current = options.find((o) => o.value === value) ?? options[0]
|
const current = options.find((o) => o.value === value) ?? options[0]
|
||||||
|
|
||||||
|
// 打开时按触发钮位置算展开层几何:优先向下,下方放不下且上方更宽裕时翻上去
|
||||||
|
const openMenu = useCallback(() => {
|
||||||
|
const rect = btnRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect) { setOpen(true); return }
|
||||||
|
const est = Math.min(MAX_H, options.length * ITEM_H + MENU_PAD)
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - 8
|
||||||
|
const spaceAbove = rect.top - 8
|
||||||
|
const above = spaceBelow < est && spaceAbove > spaceBelow
|
||||||
|
const maxHeight = Math.max(80, Math.min(MAX_H, above ? spaceAbove : spaceBelow))
|
||||||
|
const height = Math.min(est, maxHeight)
|
||||||
|
setPos({
|
||||||
|
left: Math.max(4, Math.min(rect.left, window.innerWidth - rect.width - 4)),
|
||||||
|
top: above ? Math.max(4, rect.top - height - 4) : rect.bottom + 4,
|
||||||
|
width: rect.width,
|
||||||
|
maxHeight,
|
||||||
|
})
|
||||||
|
setOpen(true)
|
||||||
|
}, [options.length])
|
||||||
|
|
||||||
// 打开时把焦点放到选中项,便于直接上下键选行
|
// 打开时把焦点放到选中项,便于直接上下键选行
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -28,6 +61,14 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open ])
|
}, [open ])
|
||||||
|
|
||||||
|
// 窗口尺寸变化时展开层位置失效,直接关闭(与菜单类组件惯例一致)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onResize = () => setOpen(false)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
return () => window.removeEventListener('resize', onResize)
|
||||||
|
}, [open ])
|
||||||
|
|
||||||
const pick = (v: string) => { setOpen(false); onChange(v) }
|
const pick = (v: string) => { setOpen(false); onChange(v) }
|
||||||
|
|
||||||
const onMenuKey = (e: React.KeyboardEvent) => {
|
const onMenuKey = (e: React.KeyboardEvent) => {
|
||||||
@@ -45,17 +86,23 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
|||||||
<div className="dropdown" ref={ref}>
|
<div className="dropdown" ref={ref}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={btnRef}
|
||||||
className="dropdown-button"
|
className="dropdown-button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => { if (open) setOpen(false); else openMenu() }}
|
||||||
>
|
>
|
||||||
<span className="dropdown-label">{current?.label ?? ''}</span>
|
<span className="dropdown-label">{current?.label ?? ''}</span>
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && pos && createPortal(
|
||||||
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}>
|
<div
|
||||||
|
className="dropdown-menu"
|
||||||
|
ref={menuRef}
|
||||||
|
onKeyDown={onMenuKey}
|
||||||
|
style={{ position: 'fixed', left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight, zIndex: 200 }}
|
||||||
|
>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o.value}
|
key={o.value}
|
||||||
@@ -66,7 +113,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
|||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useDismiss } from '../hooks/useDismiss'
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
|
import { clampMenuPosition } from '../utils/menuPos'
|
||||||
|
import { isUploadableImageSrc } from '../services/s3'
|
||||||
|
|
||||||
export type EditorMenuAction =
|
export type EditorMenuAction =
|
||||||
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||||
| 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link'
|
| 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link'
|
||||||
| 'toggleSource'
|
| 'toggleSource' | 'uploadS3' | 'imageSize'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
/** 当前是否有选区(决定剪切/复制是否可用) */
|
/** 当前是否有选区(决定剪切/复制是否可用) */
|
||||||
hasSelection: boolean
|
hasSelection: boolean
|
||||||
|
/** 右键命中的图片节点 src(非图片为 null);命中图片时换成精简的图片专用菜单 */
|
||||||
|
imageSrc: string | null
|
||||||
sourceMode: boolean
|
sourceMode: boolean
|
||||||
onAction: (action: EditorMenuAction) => void
|
onAction: (action: EditorMenuAction) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -20,15 +24,18 @@ interface Props {
|
|||||||
* 编辑区自绘右键菜单(替代 WebView 自带菜单):编辑命令与顶部「编辑/格式」
|
* 编辑区自绘右键菜单(替代 WebView 自带菜单):编辑命令与顶部「编辑/格式」
|
||||||
* 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里,
|
* 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里,
|
||||||
* execCommand 类命令才作用得准。
|
* execCommand 类命令才作用得准。
|
||||||
|
* 右键命中图片时只给图片相关项(docs/31):超链接/大小/上传 + 剪切/复制/
|
||||||
|
* 粘贴;加粗等文本格式项、全选、源码模式对图片没有意义,不再出现。
|
||||||
*/
|
*/
|
||||||
export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAction, onClose }: Props) {
|
export default function EditorContextMenu({ x, y, hasSelection, imageSrc, sourceMode, onAction, onClose }: Props) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
useDismiss(ref, true, onClose)
|
useDismiss(ref, true, onClose)
|
||||||
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
|
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
|
||||||
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式)
|
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
|
||||||
const left = Math.max(4, Math.min(x, window.innerWidth - 196))
|
const isImage = Boolean(imageSrc)
|
||||||
const estHeight = sourceMode ? 236 : 388
|
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与条目数)
|
||||||
const top = Math.max(4, Math.min(y, window.innerHeight - estHeight - 8))
|
const height = isImage ? (showUpload ? 292 : 252) : (sourceMode ? 236 : 388)
|
||||||
|
const { left, top } = clampMenuPosition(x, y, 188, height)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="tree-context-menu editor-context-menu"
|
className="tree-context-menu editor-context-menu"
|
||||||
@@ -38,22 +45,36 @@ export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAc
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<button disabled={!hasSelection} onClick={() => act('cut')}>剪切</button>
|
{isImage ? (
|
||||||
<button disabled={!hasSelection} onClick={() => act('copy')}>复制</button>
|
|
||||||
<button onClick={() => act('paste')}>粘贴</button>
|
|
||||||
<button onClick={() => act('selectAll')}>全选</button>
|
|
||||||
{!sourceMode && (
|
|
||||||
<>
|
<>
|
||||||
<div className="tree-menu-divider" />
|
|
||||||
<button onClick={() => act('bold')}>加粗</button>
|
|
||||||
<button onClick={() => act('italic')}>斜体</button>
|
|
||||||
<button onClick={() => act('strikethrough')}>删除线</button>
|
|
||||||
<button onClick={() => act('inlineCode')}>行内代码</button>
|
|
||||||
<button onClick={() => act('link')}>超链接…</button>
|
<button onClick={() => act('link')}>超链接…</button>
|
||||||
|
<button onClick={() => act('imageSize')}>调整图片大小…</button>
|
||||||
|
{showUpload && <button onClick={() => act('uploadS3')}>上传到 S3</button>}
|
||||||
|
<div className="tree-menu-divider" />
|
||||||
|
<button disabled={!hasSelection} onClick={() => act('cut')}>剪切</button>
|
||||||
|
<button disabled={!hasSelection} onClick={() => act('copy')}>复制</button>
|
||||||
|
<button onClick={() => act('paste')}>粘贴</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button disabled={!hasSelection} onClick={() => act('cut')}>剪切</button>
|
||||||
|
<button disabled={!hasSelection} onClick={() => act('copy')}>复制</button>
|
||||||
|
<button onClick={() => act('paste')}>粘贴</button>
|
||||||
|
<button onClick={() => act('selectAll')}>全选</button>
|
||||||
|
{!sourceMode && (
|
||||||
|
<>
|
||||||
|
<div className="tree-menu-divider" />
|
||||||
|
<button onClick={() => act('bold')}>加粗</button>
|
||||||
|
<button onClick={() => act('italic')}>斜体</button>
|
||||||
|
<button onClick={() => act('strikethrough')}>删除线</button>
|
||||||
|
<button onClick={() => act('inlineCode')}>行内代码</button>
|
||||||
|
<button onClick={() => act('link')}>超链接…</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="tree-menu-divider" />
|
||||||
|
<button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="tree-menu-divider" />
|
|
||||||
<button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import TableToolbar from './TableToolbar'
|
|||||||
import type { Editor } from '@milkdown/kit/core'
|
import type { Editor } from '@milkdown/kit/core'
|
||||||
import { editorViewCtx } from '@milkdown/kit/core'
|
import { editorViewCtx } from '@milkdown/kit/core'
|
||||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||||
import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/editor'
|
import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc as replaceImageSrcInEditor, imageSrcAtElement, imageInfoAtElement, updateImageAtElement, selectImageAtElement, type CursorInfo, type ImageNodeInfo, type ImageNodePatch } from '../editor/editor'
|
||||||
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
|
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
|
||||||
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
|
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
|
||||||
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
|
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
|
||||||
@@ -19,6 +19,18 @@ export interface EditorPaneApi {
|
|||||||
replaceCurrent: (replacement: string) => boolean
|
replaceCurrent: (replacement: string) => boolean
|
||||||
replaceAll: (replacement: string) => number
|
replaceAll: (replacement: string) => number
|
||||||
clearFind: () => void
|
clearFind: () => void
|
||||||
|
/** 文档里全部图片 src(去重)——S3 上传扫描用 */
|
||||||
|
listImages: () => string[]
|
||||||
|
/** 把 src 为 oldSrc 的图片节点全部换成 newSrc,返回替换数 */
|
||||||
|
replaceImageSrc: (oldSrc: string, newSrc: string) => number
|
||||||
|
/** 某 DOM 元素对应的图片节点 src(右键命中图片判定) */
|
||||||
|
imageSrcForElement: (el: HTMLElement) => string | null
|
||||||
|
/** 某图片元素的节点信息(地址/替代文本/尺寸/包裹链接) */
|
||||||
|
imageInfoForElement: (el: HTMLElement) => ImageNodeInfo | null
|
||||||
|
/** 改某图片元素的节点属性(尺寸/链接等),成功返回 true */
|
||||||
|
updateImageForElement: (el: HTMLElement, patch: ImageNodePatch) => boolean
|
||||||
|
/** 选中某图片元素对应的节点(右键图片时用) */
|
||||||
|
selectImageForElement: (el: HTMLElement) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -48,6 +60,12 @@ const EMPTY_API: EditorPaneApi = {
|
|||||||
replaceCurrent: () => false,
|
replaceCurrent: () => false,
|
||||||
replaceAll: () => 0,
|
replaceAll: () => 0,
|
||||||
clearFind: () => undefined,
|
clearFind: () => undefined,
|
||||||
|
listImages: () => [],
|
||||||
|
replaceImageSrc: () => 0,
|
||||||
|
imageInfoForElement: () => null,
|
||||||
|
updateImageForElement: () => false,
|
||||||
|
selectImageForElement: () => false,
|
||||||
|
imageSrcForElement: () => null,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
|
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
|
||||||
@@ -70,7 +88,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
|||||||
if (!wrap || !el) { setTableBar(null); return }
|
if (!wrap || !el) { setTableBar(null); return }
|
||||||
const wr = wrap.getBoundingClientRect()
|
const wr = wrap.getBoundingClientRect()
|
||||||
const tr = el.getBoundingClientRect()
|
const tr = el.getBoundingClientRect()
|
||||||
setTableBar({ top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) })
|
const next = { top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) }
|
||||||
|
setTableBar((cur) => (cur && cur.top === next.top && cur.left === next.left ? cur : next))
|
||||||
}, [])
|
}, [])
|
||||||
const hostRef = useRef<HTMLDivElement>(null)
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
const initialRef = useRef(initialMarkdown)
|
const initialRef = useRef(initialMarkdown)
|
||||||
@@ -97,7 +116,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
|||||||
const lineCenter = (coords.top + coords.bottom) / 2 - rect.top + container.scrollTop
|
const lineCenter = (coords.top + coords.bottom) / 2 - rect.top + container.scrollTop
|
||||||
const target = lineCenter - container.clientHeight / 2
|
const target = lineCenter - container.clientHeight / 2
|
||||||
if (Math.abs(target - container.scrollTop) > 6) {
|
if (Math.abs(target - container.scrollTop) > 6) {
|
||||||
container.scrollTo({ top: target, behavior: 'smooth' })
|
container.scrollTo({ top: target, behavior: 'auto' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch { /* 视图未就绪时跳过 */ }
|
} catch { /* 视图未就绪时跳过 */ }
|
||||||
@@ -230,14 +249,27 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
|||||||
const clearFind = () => {
|
const clearFind = () => {
|
||||||
findRef.current = { matches: [], current: -1, query: '', opts: null }
|
findRef.current = { matches: [], current: -1, query: '', opts: null }
|
||||||
}
|
}
|
||||||
|
const listImages = (): string[] => {
|
||||||
|
try { return collectImageSrcs(editor) } catch { return [] }
|
||||||
|
}
|
||||||
|
const replaceImageSrc = (oldSrc: string, newSrc: string): number => {
|
||||||
|
try { return replaceImageSrcInEditor(editor, oldSrc, newSrc) } catch { return 0 }
|
||||||
|
}
|
||||||
|
const imageSrcForElement = (el: HTMLElement): string | null => imageSrcAtElement(editor, el)
|
||||||
|
const imageInfoForElement = (el: HTMLElement): ImageNodeInfo | null => imageInfoAtElement(editor, el)
|
||||||
|
const updateImageForElement = (el: HTMLElement, patch: ImageNodePatch): boolean => updateImageAtElement(editor, el, patch)
|
||||||
|
const selectImageForElement = (el: HTMLElement): boolean => selectImageAtElement(editor, el)
|
||||||
editor.create()
|
editor.create()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
|
||||||
|
// 注销并 destroy,这里若继续会把已销毁编辑器的 API 注册回 App
|
||||||
|
if (cancelled) { void editor.destroy().catch(() => undefined); return }
|
||||||
setReady(true)
|
setReady(true)
|
||||||
setFocusModeEnabled(modesRef.current.focusMode)
|
setFocusModeEnabled(modesRef.current.focusMode)
|
||||||
try {
|
try {
|
||||||
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
|
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
|
||||||
} catch { /* 忽略 */ }
|
} catch { /* 忽略 */ }
|
||||||
callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind })
|
callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind, listImages, replaceImageSrc, imageSrcForElement, imageInfoForElement, updateImageForElement, selectImageForElement })
|
||||||
if (chunkLoad) {
|
if (chunkLoad) {
|
||||||
setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
|
setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
|
||||||
void chunkLoad.appendRest(
|
void chunkLoad.appendRest(
|
||||||
@@ -267,7 +299,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
|||||||
})
|
})
|
||||||
.catch((e) => console.error('编辑器创建失败', e))
|
.catch((e) => console.error('编辑器创建失败', e))
|
||||||
const scrollParent = wrapRef.current?.closest('.editor-scroll')
|
const scrollParent = wrapRef.current?.closest('.editor-scroll')
|
||||||
const onScroll = () => { if (editorObjRef.current) updateTableBar() }
|
let scrollRaf = 0
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!editorObjRef.current || scrollRaf) return
|
||||||
|
scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; updateTableBar() })
|
||||||
|
}
|
||||||
scrollParent?.addEventListener('scroll', onScroll, { passive: true })
|
scrollParent?.addEventListener('scroll', onScroll, { passive: true })
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
@@ -275,9 +311,15 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
|||||||
editorObjRef.current = null
|
editorObjRef.current = null
|
||||||
setTableBar(null)
|
setTableBar(null)
|
||||||
try {
|
try {
|
||||||
const finalMarkdown = getMarkdown(editor)
|
// 分片载入未收尾(muted)时编辑器里只有部分文档:跳过最终回写。
|
||||||
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
|
// 状态里存的是磁盘完整原文,回写部分序列化会把文档静默截断
|
||||||
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
|
// (syncEditorContent 对干净标签会把 savedContent 一起对齐)。
|
||||||
|
// 载入失败路径已主动解除 muted 并如实回写,不受此守卫影响。
|
||||||
|
if (!muted) {
|
||||||
|
const finalMarkdown = getMarkdown(editor)
|
||||||
|
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
|
||||||
|
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
|
||||||
|
}
|
||||||
} catch { /* 编辑器未就绪时跳过回写 */ }
|
} catch { /* 编辑器未就绪时跳过回写 */ }
|
||||||
callbacksRef.current.onReady(EMPTY_API)
|
callbacksRef.current.onReady(EMPTY_API)
|
||||||
editor.destroy().catch(() => undefined)
|
editor.destroy().catch(() => undefined)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import Tooltip from './Tooltip'
|
||||||
import type { FindOptions } from '../editor/search'
|
import type { FindOptions } from '../editor/search'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,21 +30,26 @@ export default function FindBar(p: Props) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggle = (key: keyof FindOptions, label: string, title: string) => (
|
const toggle = (key: keyof FindOptions, label: string, title: string) => (
|
||||||
<button
|
<Tooltip label={title}>
|
||||||
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
|
<button
|
||||||
title={title}
|
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
|
||||||
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
|
aria-label={title}
|
||||||
>
|
aria-pressed={Boolean(p.opts[key])}
|
||||||
{label}
|
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
|
||||||
</button>
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="findbar">
|
<div className="findbar">
|
||||||
<div className="findbar-row">
|
<div className="findbar-row">
|
||||||
<button className="find-icon" title={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
|
<Tooltip label={p.showReplace ? '收起替换' : '展开替换'}>
|
||||||
{p.showReplace ? '▾' : '▸'}
|
<button className="find-icon" aria-label={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
|
||||||
</button>
|
{p.showReplace ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
<input
|
<input
|
||||||
ref={queryRef}
|
ref={queryRef}
|
||||||
className="input find-input"
|
className="input find-input"
|
||||||
@@ -61,9 +67,9 @@ export default function FindBar(p: Props) {
|
|||||||
<span className="find-count">
|
<span className="find-count">
|
||||||
{p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''}
|
{p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''}
|
||||||
</span>
|
</span>
|
||||||
<button className="find-icon" title="上一个 (Shift+Enter)" onClick={p.onPrev}>↑</button>
|
<Tooltip label="上一个 (Shift+Enter)"><button className="find-icon" aria-label="上一个" onClick={p.onPrev}>↑</button></Tooltip>
|
||||||
<button className="find-icon" title="下一个 (Enter)" onClick={p.onNext}>↓</button>
|
<Tooltip label="下一个 (Enter)"><button className="find-icon" aria-label="下一个" onClick={p.onNext}>↓</button></Tooltip>
|
||||||
<button className="find-icon" title="关闭 (Esc)" onClick={p.onClose}>✕</button>
|
<Tooltip label="关闭 (Esc)"><button className="find-icon" aria-label="关闭" onClick={p.onClose}>✕</button></Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{p.showReplace && (
|
{p.showReplace && (
|
||||||
<div className="findbar-row">
|
<div className="findbar-row">
|
||||||
|
|||||||
87
src/components/GitSyncModal.tsx
Normal file
87
src/components/GitSyncModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Git 同步设置:远程仓库地址、(初始化时的)分支、定时同步间隔。
|
||||||
|
// 工作区还不是仓库时,保存=初始化仓库并绑定远程;已是仓库时只更新设置。
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Dropdown } from './Dropdown'
|
||||||
|
|
||||||
|
const INTERVAL_OPTIONS = [
|
||||||
|
{ value: '0', label: '关闭(只手动同步)' },
|
||||||
|
{ value: '5', label: '每 5 分钟' },
|
||||||
|
{ value: '15', label: '每 15 分钟' },
|
||||||
|
{ value: '30', label: '每 30 分钟' },
|
||||||
|
{ value: '60', label: '每小时' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialIntervalMin, busy, error, onSave, onClose }: {
|
||||||
|
isRepo: boolean
|
||||||
|
branch: string | null
|
||||||
|
initialRemoteUrl: string
|
||||||
|
initialIntervalMin: number
|
||||||
|
busy: boolean
|
||||||
|
error: string | null
|
||||||
|
onSave: (remoteUrl: string, branch: string, intervalMin: number) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [busy, onClose])
|
||||||
|
const [remoteUrl, setRemoteUrl] = useState(initialRemoteUrl)
|
||||||
|
const [branchInput, setBranchInput] = useState('main')
|
||||||
|
const [intervalMin, setIntervalMin] = useState(String(initialIntervalMin))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={() => { if (!busy) onClose() }}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Git 同步设置</h3>
|
||||||
|
<p className="modal-note">
|
||||||
|
同步顺序:提交本地更改 → 拉取远程更新(变基)→ 推送。若本地与远程改了相同内容,
|
||||||
|
同步会停下并提示,不会强行覆盖,需要在该文件夹手动处理。
|
||||||
|
同步范围是文件夹内的全部文件(.gitignore 排除的除外);新建仓库时会自动生成
|
||||||
|
一份 .gitignore,排除系统垃圾、临时文件与 .env。
|
||||||
|
</p>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>远程仓库地址(origin)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={remoteUrl}
|
||||||
|
placeholder="https://github.com/example/example.git"
|
||||||
|
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{isRepo ? (
|
||||||
|
<p className="modal-note">当前分支:{branch ?? '(无)'}。分支切换请用 Git 工具完成,这里只管同步。</p>
|
||||||
|
) : (
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>初始分支</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={branchInput}
|
||||||
|
onChange={(e) => setBranchInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>定时同步</span>
|
||||||
|
<Dropdown
|
||||||
|
value={intervalMin}
|
||||||
|
options={INTERVAL_OPTIONS}
|
||||||
|
onChange={setIntervalMin}
|
||||||
|
ariaLabel="定时同步间隔"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="modal-error">{error}</p>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={onClose} disabled={busy}>取消</button>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSave(remoteUrl.trim(), branchInput.trim() || 'main', Number(intervalMin) || 0)}
|
||||||
|
>
|
||||||
|
{busy ? '处理中…' : isRepo ? '保存设置' : '初始化并启用'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,6 +28,24 @@ export function ConfirmSaveModal({ title, onSave, onDiscard, onCancel }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EncodingConvertModal({ encoding, fileName, onConfirm, onCancel }: {
|
||||||
|
encoding: string
|
||||||
|
fileName: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Shell onCancel={onCancel}>
|
||||||
|
<h3>文档编码将转为 UTF-8</h3>
|
||||||
|
<p>「{fileName}」原是 {encoding} 编码。本编辑器统一以 UTF-8 保存,保存后文件编码将转为 UTF-8(内容不变)。本次运行内,同一文件只提示这一次。</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={onCancel}>取消保存</button>
|
||||||
|
<button className="primary" onClick={onConfirm}>转为 UTF-8 保存</button>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AboutModal({ onClose }: { onClose: () => void }) {
|
export function AboutModal({ onClose }: { onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Shell onCancel={onClose}>
|
<Shell onCancel={onClose}>
|
||||||
@@ -122,11 +140,19 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LinkModal({ onSubmit, onCancel }: { onSubmit: (href: string) => void; onCancel: () => void }) {
|
export function LinkModal({ onSubmit, onCancel, initialHref, heading, hint }: {
|
||||||
const [href, setHref] = useState('https://')
|
onSubmit: (href: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
/** 预填地址(编辑已有链接时用);缺省为 https:// 前缀 */
|
||||||
|
initialHref?: string
|
||||||
|
heading?: string
|
||||||
|
hint?: string
|
||||||
|
}) {
|
||||||
|
const [href, setHref] = useState(initialHref ?? 'https://')
|
||||||
return (
|
return (
|
||||||
<Shell onCancel={onCancel}>
|
<Shell onCancel={onCancel}>
|
||||||
<h3>插入链接</h3>
|
<h3>{heading ?? '插入链接'}</h3>
|
||||||
|
{hint && <p className="modal-note">{hint}</p>}
|
||||||
<input
|
<input
|
||||||
className="modal-input"
|
className="modal-input"
|
||||||
value={href}
|
value={href}
|
||||||
@@ -143,6 +169,107 @@ export function LinkModal({ onSubmit, onCancel }: { onSubmit: (href: string) =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片大小弹窗(docs/31):宽度数值 + 单位(px / %)。
|
||||||
|
* 确定返回 '300' 或 '50%' 形式的字符串;「恢复原始大小」返回 null(去掉尺寸)。
|
||||||
|
*/
|
||||||
|
export function ImageSizeModal({ current, onSubmit, onCancel }: {
|
||||||
|
current: string | null
|
||||||
|
onSubmit: (width: string | null) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const parsed = (() => {
|
||||||
|
if (!current) return { num: '', unit: 'px' as const }
|
||||||
|
const m = /^(\d+(?:\.\d+)?)(%|px)?$/.exec(current.trim())
|
||||||
|
if (m) return { num: m[1], unit: (m[2] === '%' ? '%' : 'px') as '%' | 'px' }
|
||||||
|
return { num: '', unit: 'px' as const }
|
||||||
|
})()
|
||||||
|
const [num, setNum] = useState(parsed.num)
|
||||||
|
const [unit, setUnit] = useState<'px' | '%'>(parsed.unit)
|
||||||
|
const value = Number(num)
|
||||||
|
const valid = num.trim() !== '' && Number.isFinite(value) && value > 0 && (unit === '%' ? value <= 100 : value <= 10000)
|
||||||
|
const submit = () => { if (valid) onSubmit(unit === '%' ? `${value}%` : `${value}`) }
|
||||||
|
return (
|
||||||
|
<Shell onCancel={onCancel}>
|
||||||
|
<h3>调整图片大小</h3>
|
||||||
|
<div className="modal-field-row">
|
||||||
|
<input
|
||||||
|
className="modal-input"
|
||||||
|
value={num}
|
||||||
|
autoFocus
|
||||||
|
inputMode="decimal"
|
||||||
|
onChange={(e) => setNum(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
|
||||||
|
placeholder={unit === '%' ? '如 50' : '如 600'}
|
||||||
|
/>
|
||||||
|
<div className="seg-control" role="group" aria-label="单位">
|
||||||
|
<button className={unit === 'px' ? 'active' : ''} onClick={() => setUnit('px')}>像素</button>
|
||||||
|
<button className={unit === '%' ? 'active' : ''} onClick={() => setUnit('%')}>百分比</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="modal-note">宽度按{unit === '%' ? '段落宽度的百分比' : '像素'}缩放,高度等比。大小会写进文档(带尺寸的图片以 HTML 形式保存)。</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
{current && <button onClick={() => onSubmit(null)}>恢复原始大小</button>}
|
||||||
|
<button onClick={onCancel}>取消</button>
|
||||||
|
<button className="primary" disabled={!valid} onClick={submit}>确定</button>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片链接弹窗(docs/31 更正):右键图片 →「超链接…」。
|
||||||
|
* 主字段是图片自己的地址(src)——打开就带出来,可查看也可改;
|
||||||
|
* 第二字段是可选的「点击图片时打开的链接」(包裹链接,可空)。
|
||||||
|
*/
|
||||||
|
export function ImageLinkModal({ src, linkHref, onSubmit, onCancel }: {
|
||||||
|
src: string
|
||||||
|
linkHref: string
|
||||||
|
onSubmit: (next: { src: string; linkHref: string }) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [addr, setAddr] = useState(src)
|
||||||
|
const [link, setLink] = useState(linkHref)
|
||||||
|
const valid = addr.trim() !== ''
|
||||||
|
const submit = () => { if (valid) onSubmit({ src: addr.trim(), linkHref: link.trim() }) }
|
||||||
|
return (
|
||||||
|
<Shell onCancel={onCancel}>
|
||||||
|
<h3>图片链接</h3>
|
||||||
|
<label className="modal-field-label">图片地址</label>
|
||||||
|
<input
|
||||||
|
className="modal-input"
|
||||||
|
value={addr}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setAddr(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
|
||||||
|
placeholder="图片地址(本地相对路径或 https://…)"
|
||||||
|
/>
|
||||||
|
<label className="modal-field-label">点击时打开的链接(可空)</label>
|
||||||
|
<input
|
||||||
|
className="modal-input"
|
||||||
|
value={link}
|
||||||
|
onChange={(e) => setLink(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
|
||||||
|
placeholder="可空:填了之后点击图片会跳转到这个地址"
|
||||||
|
/>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={onCancel}>取消</button>
|
||||||
|
<button className="primary" disabled={!valid} onClick={submit}>确定</button>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoModal({ title, message, onClose }: { title: string; message: string; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<Shell onCancel={onClose}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{message}</p>
|
||||||
|
<div className="modal-actions"><button className="primary" onClick={onClose}>知道了</button></div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
|
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Shell onCancel={onClose}>
|
<Shell onCancel={onClose}>
|
||||||
|
|||||||
132
src/components/S3SettingsModal.tsx
Normal file
132
src/components/S3SettingsModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// S3 图片上传设置(docs/30):服务端点/区域/存储桶/密钥/前缀/访问域名,
|
||||||
|
// 以及「自动上传图片」开关。密钥只保存在本机偏好库里,随调用传给 Rust
|
||||||
|
// 签名上传,不写进任何文档、也不会被 Git 同步带走。
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { s3TestConnection } from '../services/platform'
|
||||||
|
import { s3ConfigPayload, type S3Settings } from '../services/s3'
|
||||||
|
|
||||||
|
export function S3SettingsModal({ initial, onSave, onClose }: {
|
||||||
|
initial: S3Settings
|
||||||
|
onSave: (next: S3Settings) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<S3Settings>(initial)
|
||||||
|
const [testState, setTestState] = useState<{ kind: 'idle' | 'busy' | 'ok' | 'err'; text: string }>({ kind: 'idle', text: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const set = (patch: Partial<S3Settings>) => {
|
||||||
|
setDraft((d) => ({ ...d, ...patch }))
|
||||||
|
setTestState({ kind: 'idle', text: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
setTestState({ kind: 'busy', text: '正在连接…' })
|
||||||
|
try {
|
||||||
|
const msg = await s3TestConnection(s3ConfigPayload(draft))
|
||||||
|
setTestState({ kind: 'ok', text: msg })
|
||||||
|
} catch (e) {
|
||||||
|
setTestState({ kind: 'err', text: e instanceof Error ? e.message : String(e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>S3 图片上传设置</h3>
|
||||||
|
<p className="modal-note">
|
||||||
|
图片上传到你自己的 S3 兼容存储(AWS S3、MinIO、Cloudflare R2、阿里云 OSS 等),
|
||||||
|
文档里的图片引用随之换成公开访问地址。密钥只保存在本机设置里,不会写进文档。
|
||||||
|
</p>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>服务端点(Endpoint,可空 = AWS S3)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.endpoint}
|
||||||
|
placeholder="https://s3.example.com"
|
||||||
|
onChange={(e) => set({ endpoint: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>区域(Region)*</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.region}
|
||||||
|
placeholder="us-east-1"
|
||||||
|
onChange={(e) => set({ region: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>存储桶(Bucket)*</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.bucket}
|
||||||
|
placeholder="my-images"
|
||||||
|
onChange={(e) => set({ bucket: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>Access Key *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.accessKey}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => set({ accessKey: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>Secret Key *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={draft.secretKey}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => set({ secretKey: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>路径前缀(可空,对象键 = 前缀 + 文件名)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.prefix}
|
||||||
|
placeholder="notes/"
|
||||||
|
onChange={(e) => set({ prefix: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>自定义访问域名(可空;回填地址 = 此域名 / 存储桶 / 前缀 / 文件名,适合 MinIO 等 API 与访问地址分离的部署)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.publicBase}
|
||||||
|
placeholder="https://cdn.example.com"
|
||||||
|
onChange={(e) => set({ publicBase: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.autoUpload}
|
||||||
|
onChange={(e) => set({ autoUpload: e.target.checked })}
|
||||||
|
/>
|
||||||
|
自动上传图片(粘贴/插入的图片落盘后自动上传,并换成 S3 地址)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{testState.kind !== 'idle' && (
|
||||||
|
<p className="modal-note" style={testState.kind === 'err' ? { color: 'var(--danger, #c0392b)' } : undefined}>
|
||||||
|
{testState.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" onClick={() => void test()} disabled={testState.kind === 'busy'}>测试连接</button>
|
||||||
|
<button type="button" onClick={onClose}>取消</button>
|
||||||
|
<button type="button" className="primary" onClick={() => onSave(draft)}>保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useDismiss } from '../hooks/useDismiss'
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
import Tooltip from './Tooltip'
|
import Tooltip from './Tooltip'
|
||||||
|
import { clampMenuPosition } from '../utils/menuPos'
|
||||||
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
|
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
|
||||||
import { isPathInside, parentDirOf, pathsEqual } from '../utils/text'
|
import { isPathInside, parentDirOf, pathsEqual } from '../utils/text'
|
||||||
import type { OutlineItem } from '../utils/text'
|
import type { OutlineItem } from '../utils/text'
|
||||||
import { baseName } from '../utils/text'
|
import { baseName } from '../utils/text'
|
||||||
|
|
||||||
|
/** Git 同步条所需的展示状态与动作(由 App 持有真实状态)。 */
|
||||||
|
export interface GitSyncUi {
|
||||||
|
isRepo: boolean
|
||||||
|
label: string
|
||||||
|
syncing: boolean
|
||||||
|
lastMessage: string | null
|
||||||
|
lastIsError: boolean
|
||||||
|
onSyncNow: () => void
|
||||||
|
onOpenSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'files' | 'outline' | 'search'
|
mode: 'files' | 'outline' | 'search'
|
||||||
onModeChange: (mode: 'files' | 'outline' | 'search') => void
|
onModeChange: (mode: 'files' | 'outline' | 'search') => void
|
||||||
@@ -33,6 +45,8 @@ interface Props {
|
|||||||
outline: OutlineItem[]
|
outline: OutlineItem[]
|
||||||
onJumpHeading: (item: OutlineItem) => void
|
onJumpHeading: (item: OutlineItem) => void
|
||||||
tauriMode: boolean
|
tauriMode: boolean
|
||||||
|
/** Git 同步条(仅桌面模式且已打开文件夹时由 App 传入) */
|
||||||
|
gitSync?: GitSyncUi
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar(p: Props) {
|
function Sidebar(p: Props) {
|
||||||
@@ -67,7 +81,10 @@ function Sidebar(p: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : p.workspaceRoot ? (
|
) : p.workspaceRoot ? (
|
||||||
|
<>
|
||||||
|
{p.gitSync && <GitSyncBar ui={p.gitSync} />}
|
||||||
<FileTree
|
<FileTree
|
||||||
|
key={p.workspaceRoot}
|
||||||
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
|
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
|
||||||
onSaveAsEntry={p.onSaveAsEntry}
|
onSaveAsEntry={p.onSaveAsEntry}
|
||||||
onExportEntry={p.onExportEntry}
|
onExportEntry={p.onExportEntry}
|
||||||
@@ -83,6 +100,7 @@ function Sidebar(p: Props) {
|
|||||||
onRevealEntry={p.onRevealEntry}
|
onRevealEntry={p.onRevealEntry}
|
||||||
refreshSignal={p.refreshSignal ?? 0}
|
refreshSignal={p.refreshSignal ?? 0}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="sidebar-empty center">
|
<div className="sidebar-empty center">
|
||||||
<p>尚未打开文件夹。</p>
|
<p>尚未打开文件夹。</p>
|
||||||
@@ -106,6 +124,32 @@ interface TreeDnd {
|
|||||||
drop: (dir: string) => void
|
drop: (dir: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GitSyncBar({ ui }: { ui: GitSyncUi }) {
|
||||||
|
return (
|
||||||
|
<div className="gitsync">
|
||||||
|
<div className="gitsync-bar">
|
||||||
|
<span className="gitsync-title">Git 同步</span>
|
||||||
|
<span className="gitsync-status">{ui.label}</span>
|
||||||
|
<span className="gitsync-actions">
|
||||||
|
{ui.isRepo && (
|
||||||
|
<Tooltip label="立即同步">
|
||||||
|
<button className="gitsync-btn" aria-label="立即同步" disabled={ui.syncing} onClick={ui.onSyncNow}>
|
||||||
|
<svg className={ui.syncing ? 'ws-spin' : ''} width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><path d="M20.2 3.6v4h-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Git 同步设置">
|
||||||
|
<button className="gitsync-btn" aria-label="Git 同步设置" onClick={ui.onOpenSettings}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8h10M18 8h2M4 16h4M12 16h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><circle cx="16" cy="8" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.8" /><circle cx="10" cy="16" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.8" /></svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ui.lastMessage && <div className={`gitsync-msg${ui.lastIsError ? ' error' : ''}`}>{ui.lastMessage}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry, onMoveEntry, onRevealEntry }: {
|
function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry, onMoveEntry, onRevealEntry }: {
|
||||||
root: string
|
root: string
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
@@ -175,7 +219,7 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
|
|||||||
dnd={dnd}
|
dnd={dnd}
|
||||||
/>
|
/>
|
||||||
{menu && (
|
{menu && (
|
||||||
<div className="tree-context-menu" ref={menuRef} style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
|
<div className="tree-context-menu" ref={menuRef} style={clampMenuPosition(menu.x, menu.y, 208, 340)} onClick={(e) => e.stopPropagation()}>
|
||||||
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
|
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
|
||||||
{!menu.entry.isDir && onOpenInNewWindowEntry && <button onClick={() => { onOpenInNewWindowEntry(menu.entry); setMenu(null) }}>在新窗口打开</button>}
|
{!menu.entry.isDir && onOpenInNewWindowEntry && <button onClick={() => { onOpenInNewWindowEntry(menu.entry); setMenu(null) }}>在新窗口打开</button>}
|
||||||
{onRevealEntry && <button onClick={() => { onRevealEntry(menu.entry); setMenu(null) }}>打开所在位置</button>}
|
{onRevealEntry && <button onClick={() => { onRevealEntry(menu.entry); setMenu(null) }}>打开所在位置</button>}
|
||||||
@@ -235,7 +279,7 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
|
|||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<div key={entry.path}>
|
<div key={entry.path}>
|
||||||
<button
|
<button
|
||||||
className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && entry.path === activePath ? ' active' : ''}${dnd && dnd.draggingPath === entry.path ? ' dragging' : ''}${dnd && entry.isDir && dnd.dropDir === entry.path ? ' drop-target' : ''}`}
|
className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && pathsEqual(entry.path, activePath) ? ' active' : ''}${dnd && dnd.draggingPath === entry.path ? ' dragging' : ''}${dnd && entry.isDir && dnd.dropDir === entry.path ? ' drop-target' : ''}`}
|
||||||
style={{ paddingLeft: 8 + depth * 13 }}
|
style={{ paddingLeft: 8 + depth * 13 }}
|
||||||
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
|
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
|
||||||
onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }}
|
onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }}
|
||||||
@@ -271,18 +315,22 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
|
|||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const runSeqRef = useRef(0)
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (!root || !query.trim()) return
|
if (!root || !query.trim()) return
|
||||||
|
const seq = ++runSeqRef.current
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const word = query.trim()
|
const word = query.trim()
|
||||||
setResults(await workspaceSearch(root, word, caseSensitive))
|
const found = await workspaceSearch(root, word, caseSensitive)
|
||||||
|
if (runSeqRef.current !== seq) return // 已有更新的搜索,旧结果作废
|
||||||
|
setResults(found)
|
||||||
setSearchedQuery(word)
|
setSearchedQuery(word)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e))
|
if (runSeqRef.current === seq) setError(String(e))
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false)
|
if (runSeqRef.current === seq) setSearching(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ interface Props {
|
|||||||
sourceMode: boolean
|
sourceMode: boolean
|
||||||
tauriMode: boolean
|
tauriMode: boolean
|
||||||
zoom: number
|
zoom: number
|
||||||
|
/** 当前文档未上传的本地图片数(>0 时状态栏出批量上传钮) */
|
||||||
|
pendingImages?: number
|
||||||
|
onUploadImages?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatusBar(p: Props) {
|
export default function StatusBar(p: Props) {
|
||||||
@@ -25,6 +28,11 @@ export default function StatusBar(p: Props) {
|
|||||||
<span>{p.dirty ? '未保存' : '已保存'}</span>
|
<span>{p.dirty ? '未保存' : '已保存'}</span>
|
||||||
<span>{p.sourceMode ? '源代码模式' : '即时渲染'}</span>
|
<span>{p.sourceMode ? '源代码模式' : '即时渲染'}</span>
|
||||||
<span className="status-right">
|
<span className="status-right">
|
||||||
|
{Boolean(p.pendingImages && p.onUploadImages) && (
|
||||||
|
<button type="button" className="status-upload-btn" onClick={p.onUploadImages}>
|
||||||
|
上传图片到 S3({p.pendingImages} 张未传)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span>{Math.round(p.zoom * 100)}%</span>
|
<span>{Math.round(p.zoom * 100)}%</span>
|
||||||
<span>{p.tauriMode ? '桌面模式' : 'Web 降级模式(调试)'}</span>
|
<span>{p.tauriMode ? '桌面模式' : 'Web 降级模式(调试)'}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import { clampMenuPosition } from '../utils/menuPos'
|
||||||
import type { DocTab } from '../stores/tabs'
|
import type { DocTab } from '../stores/tabs'
|
||||||
import { isDirty } from '../stores/tabs'
|
import { isDirty } from '../stores/tabs'
|
||||||
import { useDismiss } from '../hooks/useDismiss'
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
@@ -23,6 +24,7 @@ interface Props {
|
|||||||
export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOthers, onCloseRight, onNew, onMove, onSaveTab, onSaveAsTab, onOpenInNewWindowTab, onExportTab, onPrintTab }: Props) {
|
export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOthers, onCloseRight, onNew, onMove, onSaveTab, onSaveAsTab, onOpenInNewWindowTab, onExportTab, onPrintTab }: Props) {
|
||||||
const [menuFor, setMenuFor] = useState<{ id: string; x: number; y: number } | null>(null)
|
const [menuFor, setMenuFor] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||||
const [dragId, setDragId] = useState<string | null>(null)
|
const [dragId, setDragId] = useState<string | null>(null)
|
||||||
|
const lastMoveRef = useRef<string | null>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const closeMenu = useCallback(() => setMenuFor(null), [])
|
const closeMenu = useCallback(() => setMenuFor(null), [])
|
||||||
useDismiss(menuRef, menuFor !== null, closeMenu)
|
useDismiss(menuRef, menuFor !== null, closeMenu)
|
||||||
@@ -46,11 +48,12 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
|
|||||||
onDragStart={() => setDragId(tab.id)}
|
onDragStart={() => setDragId(tab.id)}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (dragId && dragId !== tab.id) {
|
if (dragId && dragId !== tab.id && lastMoveRef.current !== `${dragId}:${index}`) {
|
||||||
|
lastMoveRef.current = `${dragId}:${index}`
|
||||||
onMove(dragId, index)
|
onMove(dragId, index)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => setDragId(null)}
|
onDragEnd={() => { setDragId(null); lastMoveRef.current = null }}
|
||||||
onClick={() => onActivate(tab.id)}
|
onClick={() => onActivate(tab.id)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -70,7 +73,7 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
|
|||||||
))}
|
))}
|
||||||
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
|
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
|
||||||
{menuFor && menuTab && (
|
{menuFor && menuTab && (
|
||||||
<div className="context-menu" ref={menuRef} style={{ left: menuFor.x, top: menuFor.y }}>
|
<div className="context-menu" ref={menuRef} style={clampMenuPosition(menuFor.x, menuFor.y, 200, 330)}>
|
||||||
<button disabled={!isDirty(menuTab)} onClick={() => act(onSaveTab)}>保存</button>
|
<button disabled={!isDirty(menuTab)} onClick={() => act(onSaveTab)}>保存</button>
|
||||||
<button onClick={() => act(onSaveAsTab)}>另存为…</button>
|
<button onClick={() => act(onSaveAsTab)}>另存为…</button>
|
||||||
{onOpenInNewWindowTab && <button onClick={() => act(onOpenInNewWindowTab)}>在新窗口打开</button>}
|
{onOpenInNewWindowTab && <button onClick={() => act(onOpenInNewWindowTab)}>在新窗口打开</button>}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Editor } from '@milkdown/kit/core'
|
import type { Editor } from '@milkdown/kit/core'
|
||||||
import { editorCommands } from '../editor/commands'
|
import { editorCommands } from '../editor/commands'
|
||||||
|
import Tooltip from './Tooltip'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
@@ -10,7 +11,9 @@ interface Props {
|
|||||||
/** 表格浮动工具栏:光标在表格内时出现在表格上方(EditorPane 负责定位与显隐)。 */
|
/** 表格浮动工具栏:光标在表格内时出现在表格上方(EditorPane 负责定位与显隐)。 */
|
||||||
export default function TableToolbar({ editor, top, left }: Props) {
|
export default function TableToolbar({ editor, top, left }: Props) {
|
||||||
const btn = (label: string, fn: () => void, title?: string) => (
|
const btn = (label: string, fn: () => void, title?: string) => (
|
||||||
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} title={title ?? label}>{label}</button>
|
<Tooltip label={title ?? label}>
|
||||||
|
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} aria-label={title ?? label}>{label}</button>
|
||||||
|
</Tooltip>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<div className="table-toolbar" style={{ top, left }} role="toolbar" aria-label="表格工具">
|
<div className="table-toolbar" style={{ top, left }} role="toolbar" aria-label="表格工具">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const ASYNC_LOAD_THRESHOLD = 60_000
|
|||||||
const CHUNK_TARGET = 24_000
|
const CHUNK_TARGET = 24_000
|
||||||
|
|
||||||
const FENCE_RE = /^\s{0,3}(```+|~~~+)/
|
const FENCE_RE = /^\s{0,3}(```+|~~~+)/
|
||||||
|
/** 围栏作切点时只认顶格:列表项内缩进的围栏是列表内容,切开会改变归属(docs/29) */
|
||||||
|
const FENCE_CUT_RE = /^(`{3,}|~{3,})/
|
||||||
const HEADING_RE = /^#{1,6}\s/
|
const HEADING_RE = /^#{1,6}\s/
|
||||||
const HR_RE = /^\s{0,3}(\*\*\*+|---+|___+)\s*$/
|
const HR_RE = /^\s{0,3}(\*\*\*+|---+|___+)\s*$/
|
||||||
const LINK_DEF_RE = /^\s{0,3}\[[^\]]+\]:\s*\S+/
|
const LINK_DEF_RE = /^\s{0,3}\[[^\]]+\]:\s*\S+/
|
||||||
@@ -44,20 +46,18 @@ export function splitMarkdownChunks(markdown: string): string[] {
|
|||||||
if (inFence) continue
|
if (inFence) continue
|
||||||
if (line.trim() !== '' || i + 1 >= lines.length) continue
|
if (line.trim() !== '' || i + 1 >= lines.length) continue
|
||||||
const next = lines[i + 1]
|
const next = lines[i + 1]
|
||||||
if (HEADING_RE.test(next) || FENCE_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
|
if (HEADING_RE.test(next) || FENCE_CUT_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
|
||||||
}
|
}
|
||||||
if (cuts.length === 0) return [markdown]
|
if (cuts.length === 0) return [markdown]
|
||||||
// 按目标大小合并相邻片段:切点很多时攒够一片再切,避免碎成几百片
|
// 按目标大小合并相邻片段:切点很多时攒够一片再切,避免碎成几百片
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let startLine = 0
|
let startLine = 0
|
||||||
let startOffset = 0
|
let startOffset = 0
|
||||||
const lineOffset = (idx: number) => {
|
// 行偏移前缀表(原实现每个切点都从头累加,切点密集时 O(切点×行数))
|
||||||
let off = 0
|
const lineOffsets: number[] = [0]
|
||||||
for (let i = 0; i < idx; i++) off += lines[i].length + 1
|
for (let i = 0; i < lines.length; i++) lineOffsets.push(lineOffsets[i] + lines[i].length + 1)
|
||||||
return off
|
|
||||||
}
|
|
||||||
for (const cut of cuts) {
|
for (const cut of cuts) {
|
||||||
const off = lineOffset(cut)
|
const off = lineOffsets[cut]
|
||||||
if (off - startOffset >= CHUNK_TARGET && cut > startLine) {
|
if (off - startOffset >= CHUNK_TARGET && cut > startLine) {
|
||||||
chunks.push(markdown.slice(startOffset, off))
|
chunks.push(markdown.slice(startOffset, off))
|
||||||
startLine = cut
|
startLine = cut
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard'
|
|||||||
import { indent } from '@milkdown/kit/plugin/indent'
|
import { indent } from '@milkdown/kit/plugin/indent'
|
||||||
import { math } from '@milkdown/plugin-math'
|
import { math } from '@milkdown/plugin-math'
|
||||||
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload'
|
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload'
|
||||||
import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, createEmptyPlaceholderPlugin, selectionInTable } from './plugins'
|
import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, createImageEnterPlugin, createEmptyPlaceholderPlugin, selectionInTable } from './plugins'
|
||||||
|
import { NodeSelection } from '@milkdown/kit/prose/state'
|
||||||
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
|
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
|
||||||
|
import { extendImageSchema } from './imageSchema'
|
||||||
|
|
||||||
export interface CursorInfo { line: number; col: number }
|
export interface CursorInfo { line: number; col: number }
|
||||||
|
|
||||||
@@ -39,26 +41,41 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
|||||||
ctx.set(defaultValueCtx, initialMarkdown)
|
ctx.set(defaultValueCtx, initialMarkdown)
|
||||||
// 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-`
|
// 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-`
|
||||||
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
|
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
|
||||||
|
// 图片尺寸扩展(docs/31):width 属性 + <img> HTML 往返
|
||||||
|
extendImageSchema(ctx)
|
||||||
const lm = ctx.get(listenerCtx)
|
const lm = ctx.get(listenerCtx)
|
||||||
if (callbacks.onMarkdown) {
|
if (callbacks.onMarkdown) {
|
||||||
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
|
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
|
||||||
}
|
}
|
||||||
if (callbacks.onCursor || callbacks.onTableContext) {
|
if (callbacks.onCursor || callbacks.onTableContext) {
|
||||||
|
// 选区事件在每个事务后同步分发,而行列计算要从文档头逐块数到
|
||||||
|
// 光标(大文档 O(光标位置)):用 rAF 合并,逐帧最多算一次(docs/29)
|
||||||
|
let cursorRaf = 0
|
||||||
|
let pendingRun: (() => void) | null = null
|
||||||
lm.selectionUpdated((selCtx, selection) => {
|
lm.selectionUpdated((selCtx, selection) => {
|
||||||
callbacks.onCursor?.(cursorFromSelection(selection))
|
pendingRun = () => {
|
||||||
if (callbacks.onTableContext) {
|
callbacks.onCursor?.(cursorFromSelection(selection))
|
||||||
const inTable = selectionInTable(selection as never)
|
if (callbacks.onTableContext) {
|
||||||
let tableEl: HTMLElement | null = null
|
const inTable = selectionInTable(selection as never)
|
||||||
if (inTable) {
|
let tableEl: HTMLElement | null = null
|
||||||
try {
|
if (inTable) {
|
||||||
const view = selCtx.get(editorViewCtx)
|
try {
|
||||||
const at = view.domAtPos(selection.from)
|
const view = selCtx.get(editorViewCtx)
|
||||||
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
|
const at = view.domAtPos(selection.from)
|
||||||
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
|
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
|
||||||
} catch { /* 定位失败时工具栏不显示 */ }
|
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
|
||||||
|
} catch { /* 定位失败时工具栏不显示 */ }
|
||||||
|
}
|
||||||
|
callbacks.onTableContext(inTable, tableEl)
|
||||||
}
|
}
|
||||||
callbacks.onTableContext(inTable, tableEl)
|
|
||||||
}
|
}
|
||||||
|
if (cursorRaf) return
|
||||||
|
cursorRaf = requestAnimationFrame(() => {
|
||||||
|
cursorRaf = 0
|
||||||
|
const run = pendingRun
|
||||||
|
pendingRun = null
|
||||||
|
run?.()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ctx.update(uploadConfig.key, (prev: UploadOptions) => ({
|
ctx.update(uploadConfig.key, (prev: UploadOptions) => ({
|
||||||
@@ -96,6 +113,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
|||||||
.use(math)
|
.use(math)
|
||||||
.use(upload)
|
.use(upload)
|
||||||
.use(createPlainTextPastePlugin())
|
.use(createPlainTextPastePlugin())
|
||||||
|
.use(createImageEnterPlugin())
|
||||||
if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay))
|
if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay))
|
||||||
return editor
|
return editor
|
||||||
}
|
}
|
||||||
@@ -152,6 +170,162 @@ export function getMarkdown(editor: Editor): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 文档里全部图片的 src(按出现顺序、去重)——S3 批量上传扫描用(docs/30)。 */
|
||||||
|
export function collectImageSrcs(editor: Editor): string[] {
|
||||||
|
return editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
view.state.doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'image') {
|
||||||
|
const src = String(node.attrs.src ?? '')
|
||||||
|
if (src && !seen.has(src)) { seen.add(src); out.push(src) }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把文档里所有 src === oldSrc 的图片节点换成 newSrc,返回替换数。
|
||||||
|
* 一次遍历收集位置、同一事务内 setNodeMarkup:image 是叶子节点,
|
||||||
|
* 改属性不改变文档尺寸,位置不会失效。
|
||||||
|
*/
|
||||||
|
export function replaceImageSrc(editor: Editor, oldSrc: string, newSrc: string): number {
|
||||||
|
return editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx)
|
||||||
|
const positions: number[] = []
|
||||||
|
view.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === 'image' && String(node.attrs.src ?? '') === oldSrc) positions.push(pos)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (positions.length === 0) return 0
|
||||||
|
const tr = view.state.tr
|
||||||
|
for (const pos of positions) {
|
||||||
|
const node = view.state.doc.nodeAt(pos)
|
||||||
|
if (node) tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc })
|
||||||
|
}
|
||||||
|
view.dispatch(tr)
|
||||||
|
return positions.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右键命中的 <img> 元素对应的图片节点 src(非图片返回 null)——右键菜单判「本地未上传图」用。 */
|
||||||
|
export function imageSrcAtElement(editor: Editor, el: HTMLElement): string | null {
|
||||||
|
try {
|
||||||
|
return editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx)
|
||||||
|
const pos = view.posAtDOM(el, 0)
|
||||||
|
const node = view.state.doc.nodeAt(pos)
|
||||||
|
if (!node || node.type.name !== 'image') return null
|
||||||
|
return String(node.attrs.src ?? '') || null
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageNodeInfo {
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
/** 尺寸('300' = px、'50%' = 百分比);未设置时为 null */
|
||||||
|
width: string | null
|
||||||
|
/** 图片被链接包裹时的链接地址(链接标记的 href);没有时为 null */
|
||||||
|
linkHref: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageNodePatch {
|
||||||
|
src?: string
|
||||||
|
alt?: string
|
||||||
|
width?: string | null
|
||||||
|
/** undefined = 不动链接;null/'' = 去掉链接;字符串 = 设置/更新链接 */
|
||||||
|
linkHref?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function imagePosAtElement(view: { posAtDOM: (el: Node, off: number) => number; state: { doc: { nodeAt: (pos: number) => unknown } } }, el: HTMLElement): number {
|
||||||
|
const pos = view.posAtDOM(el, 0)
|
||||||
|
const node = view.state.doc.nodeAt(pos) as { type: { name: string } } | null
|
||||||
|
if (!node || node.type.name !== 'image') return -1
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读某个 <img> 元素对应图片节点的属性(含尺寸与包裹链接),非图片返回 null。 */
|
||||||
|
export function imageInfoAtElement(editor: Editor, el: HTMLElement): ImageNodeInfo | null {
|
||||||
|
try {
|
||||||
|
return editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx)
|
||||||
|
const pos = imagePosAtElement(view, el)
|
||||||
|
if (pos < 0) return null
|
||||||
|
const node = view.state.doc.nodeAt(pos)
|
||||||
|
if (!node) return null
|
||||||
|
const link = node.marks.find((m) => m.type.name === 'link')
|
||||||
|
return {
|
||||||
|
src: String(node.attrs.src ?? ''),
|
||||||
|
alt: String(node.attrs.alt ?? ''),
|
||||||
|
width: (node.attrs.width as string | null) ?? null,
|
||||||
|
linkHref: link ? String(link.attrs.href ?? '') || null : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改某个 <img> 元素对应图片节点的属性(地址/替代文本/尺寸/包裹链接),
|
||||||
|
* 一次事务完成。链接以标记形式挂在图片节点上,序列化为 [](href)。
|
||||||
|
*/
|
||||||
|
export function updateImageAtElement(editor: Editor, el: HTMLElement, patch: ImageNodePatch): boolean {
|
||||||
|
try {
|
||||||
|
return editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx)
|
||||||
|
const pos = imagePosAtElement(view, el)
|
||||||
|
if (pos < 0) return false
|
||||||
|
const node = view.state.doc.nodeAt(pos)
|
||||||
|
if (!node) return false
|
||||||
|
const tr = view.state.tr
|
||||||
|
const attrs = { ...node.attrs }
|
||||||
|
if (patch.src !== undefined) attrs.src = patch.src
|
||||||
|
if (patch.alt !== undefined) attrs.alt = patch.alt
|
||||||
|
if (patch.width !== undefined) attrs.width = patch.width
|
||||||
|
tr.setNodeMarkup(pos, undefined, attrs)
|
||||||
|
if (patch.linkHref !== undefined) {
|
||||||
|
const linkType = view.state.schema.marks.link
|
||||||
|
if (linkType) {
|
||||||
|
tr.removeMark(pos, pos + 1, linkType)
|
||||||
|
if (patch.linkHref) tr.addMark(pos, pos + 1, linkType.create({ href: patch.linkHref }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 同 plugins 的拖拽提交:换节点属性后保住节点选区,别被回读塌掉
|
||||||
|
if (view.state.selection instanceof NodeSelection && view.state.selection.from === pos) {
|
||||||
|
tr.setSelection(NodeSelection.create(tr.doc, pos))
|
||||||
|
}
|
||||||
|
if (tr.docChanged) view.dispatch(tr)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选中某个 <img> 元素对应的图片节点(右键图片时先选中它,剪切/复制/删除才作用得准)。 */
|
||||||
|
export function selectImageAtElement(editor: Editor, el: HTMLElement): boolean {
|
||||||
|
try {
|
||||||
|
return editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx)
|
||||||
|
const pos = imagePosAtElement(view, el)
|
||||||
|
if (pos < 0) return false
|
||||||
|
const sel = view.state.selection
|
||||||
|
if (sel instanceof NodeSelection && sel.from === pos) return true
|
||||||
|
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function callUndo(editor: Editor) {
|
export function callUndo(editor: Editor) {
|
||||||
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
|
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/editor/imageSchema.ts
Normal file
128
src/editor/imageSchema.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// 图片尺寸扩展(docs/31):给 Milkdown 图片节点加 width 属性,让「右键 →
|
||||||
|
// 调整大小」可持久化。Markdown 本身没有图片尺寸语法,业界通行写法是
|
||||||
|
// HTML:带 width 的图片序列化为 <img src alt width>,无 width 时仍是
|
||||||
|
// 标准 ,不污染普通文档。
|
||||||
|
// - 解析:remark 阶段把孤立的 <img …>(行内或块级)mdast html 节点换成
|
||||||
|
// image 节点并把 width 放进 data;图片 schema 的 parseMarkdown runner
|
||||||
|
// 读出来落到节点 attrs。
|
||||||
|
// - 序列化:toMarkdown runner 见 width 就发 html 节点,否则原样发 image。
|
||||||
|
// 往返已实测:<img src="b.png" alt="乙" width="300"> 解析/序列化逐字一致。
|
||||||
|
import type { Ctx } from '@milkdown/kit/ctx'
|
||||||
|
import { remarkPluginsCtx } from '@milkdown/kit/core'
|
||||||
|
import { imageSchema } from '@milkdown/kit/preset/commonmark'
|
||||||
|
|
||||||
|
const IMG_TAG = /^<img\s+[^>]*\/?>$/i
|
||||||
|
|
||||||
|
/** 解析一个孤立 <img> 标签的属性表;不是孤立 img 或没有 src 时返回 null。 */
|
||||||
|
export function parseImgTag(html: string): Record<string, string> | null {
|
||||||
|
const t = html.trim()
|
||||||
|
if (!IMG_TAG.test(t)) return null
|
||||||
|
const attrs: Record<string, string> = {}
|
||||||
|
for (const m of t.matchAll(/([\w-]+)\s*=\s*"([^"]*)"|([\w-]+)\s*=\s*'([^']*)'/g)) {
|
||||||
|
attrs[(m[1] ?? m[3]).toLowerCase()] = decodeAttr(m[2] ?? m[4])
|
||||||
|
}
|
||||||
|
return attrs.src ? attrs : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ATTR_ENTITIES: Record<string, string> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
||||||
|
|
||||||
|
/** 解码 HTML 属性值里的常见实体(mdast 保留的是源码原文,不解码)。单遍替换防二次解码。 */
|
||||||
|
function decodeAttr(v: string): string {
|
||||||
|
return v.replace(/&(?:quot|amp|lt|gt);|'/g, (m) => ATTR_ENTITIES[m] ?? m)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MdastNode {
|
||||||
|
type: string
|
||||||
|
value?: string
|
||||||
|
children?: MdastNode[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** remark 插件:把孤立 <img> 的 mdast html 节点换成带 data.width 的 image 节点。 */
|
||||||
|
export function remarkImgSizePlugin() {
|
||||||
|
const toImage = (child: MdastNode): MdastNode | null => {
|
||||||
|
if (child?.type !== 'html') return null
|
||||||
|
const attrs = parseImgTag(String(child.value ?? ''))
|
||||||
|
if (!attrs) return null
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
url: attrs.src,
|
||||||
|
alt: attrs.alt ?? '',
|
||||||
|
title: attrs.title ?? '',
|
||||||
|
data: { width: attrs.width ?? null },
|
||||||
|
} as MdastNode
|
||||||
|
}
|
||||||
|
const walk = (node: MdastNode): void => {
|
||||||
|
if (!node || typeof node !== 'object' || !Array.isArray(node.children)) return
|
||||||
|
node.children = node.children.map((child) => {
|
||||||
|
const img = toImage(child)
|
||||||
|
if (img) {
|
||||||
|
// 根级是块上下文,image 是行内节点,必须包进段落;段落内原地替换
|
||||||
|
return node.type === 'root' ? { type: 'paragraph', children: [img] } : img
|
||||||
|
}
|
||||||
|
walk(child)
|
||||||
|
return child
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (tree: MdastNode) => walk(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(v: string): string {
|
||||||
|
return v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在编辑器 config 阶段调用:注册 remark 插件 + 包裹图片 schema。 */
|
||||||
|
export function extendImageSchema(ctx: Ctx): void {
|
||||||
|
// remarkPluginsCtx 的条目类型是 unified 的 Plugin<[..], Root>,与本文件
|
||||||
|
// 的宽松 mdast 类型对不上;运行时形状 { plugin, options } 已实测无误。
|
||||||
|
const entry = { plugin: remarkImgSizePlugin, options: undefined } as unknown as
|
||||||
|
ReturnType<typeof remarkPluginsCtx.create> extends (infer T)[] ? T : never
|
||||||
|
ctx.update(remarkPluginsCtx, (prev) => [...prev, entry])
|
||||||
|
ctx.update(imageSchema.key, (factory) => (c) => {
|
||||||
|
const spec = factory(c)
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
attrs: { ...spec.attrs, width: { default: null } },
|
||||||
|
parseMarkdown: {
|
||||||
|
match: (node: { type: string }) => node.type === 'image',
|
||||||
|
runner: (state, node, type) => {
|
||||||
|
const mdast = node as unknown as { url: string; alt?: string; title?: string; data?: { width?: string | null } }
|
||||||
|
state.addNode(type, {
|
||||||
|
src: mdast.url,
|
||||||
|
alt: mdast.alt ?? '',
|
||||||
|
title: mdast.title ?? '',
|
||||||
|
width: mdast.data?.width ?? null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toMarkdown: {
|
||||||
|
match: (node: { type: { name: string } }) => node.type.name === 'image',
|
||||||
|
runner: (state, node) => {
|
||||||
|
const width = node.attrs.width as string | null
|
||||||
|
if (width) {
|
||||||
|
const title = node.attrs.title ? ` title="${escapeAttr(String(node.attrs.title))}"` : ''
|
||||||
|
state.addNode(
|
||||||
|
'html',
|
||||||
|
undefined,
|
||||||
|
`<img src="${escapeAttr(String(node.attrs.src))}" alt="${escapeAttr(String(node.attrs.alt))}"${title} width="${escapeAttr(String(width))}">`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state.addNode('image', undefined, undefined, {
|
||||||
|
title: node.attrs.title,
|
||||||
|
url: node.attrs.src,
|
||||||
|
alt: node.attrs.alt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** width 属性值 → CSS 宽度:纯数字按 px,带单位(%/px/em…)原样用。 */
|
||||||
|
export function widthToCss(width: string | null | undefined): string | null {
|
||||||
|
if (!width) return null
|
||||||
|
const t = String(width).trim()
|
||||||
|
if (!t) return null
|
||||||
|
return /^\d+(\.\d+)?$/.test(t) ? `${t}px` : t
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ interface ViewLike {
|
|||||||
* 不产生空段落(上一版 replaceSelectionWith + insert 在段中粘贴会留一个 <br /> 空段)。 */
|
* 不产生空段落(上一版 replaceSelectionWith + insert 在段中粘贴会留一个 <br /> 空段)。 */
|
||||||
export function insertPlainTextIntoView(view: ViewLike, text: string): boolean {
|
export function insertPlainTextIntoView(view: ViewLike, text: string): boolean {
|
||||||
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
||||||
|
// 原文以换行结尾时末项是空串:丢弃,否则粘贴后会多一个空段落
|
||||||
|
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
|
||||||
const paragraph = view.state.schema.nodes.paragraph
|
const paragraph = view.state.schema.nodes.paragraph
|
||||||
const parentType = view.state.selection.$from.parent.type.name
|
const parentType = view.state.selection.$from.parent.type.name
|
||||||
if (lines.length <= 1 || !paragraph || (parentType !== 'paragraph' && parentType !== 'heading')) {
|
if (lines.length <= 1 || !paragraph || (parentType !== 'paragraph' && parentType !== 'heading')) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。
|
// 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。
|
||||||
// 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。
|
// 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。
|
||||||
// 2) codeBlockTab —— 代码块内 Tab 缩进 / Shift+Tab 反缩进(其余位置 Tab 行为不变)。
|
// 2) codeBlockTab —— 代码块内 Tab 缩进 / Shift+Tab 反缩进(其余位置 Tab 行为不变)。
|
||||||
import { Plugin, PluginKey } from '@milkdown/kit/prose/state'
|
import { Plugin, PluginKey, NodeSelection, TextSelection } from '@milkdown/kit/prose/state'
|
||||||
|
import { widthToCss } from './imageSchema'
|
||||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||||
import { $prose } from '@milkdown/kit/utils'
|
import { $prose } from '@milkdown/kit/utils'
|
||||||
import { commandsCtx } from '@milkdown/kit/core'
|
import { commandsCtx } from '@milkdown/kit/core'
|
||||||
@@ -416,14 +417,72 @@ export function createPlainTextPastePlugin() {
|
|||||||
// resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。
|
// resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。
|
||||||
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACw='
|
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACw='
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片选中时按 Enter:在图片所在段落后新建空段落并把光标移过去(docs/31)。
|
||||||
|
* 默认 Enter 链对图片 NodeSelection 走 splitBlock——新段落插到图片前面且
|
||||||
|
* 选区仍停在图片上,接着打字会把图片替换掉,用户根本没法在图片后输入。
|
||||||
|
* handleKeyDown 先于所有 keymap 插件执行,只接管「图片被选中」这一种情形,
|
||||||
|
* 其余 Enter(列表/标题/代码块/普通文本)一律放行给原有链。
|
||||||
|
*/
|
||||||
|
export function createImageEnterPlugin() {
|
||||||
|
return $prose(() => new Plugin({
|
||||||
|
// 用插件 view 生命周期挂捕获阶段监听:预置的 Enter keymap 插件排在
|
||||||
|
// 本插件之前,props.handleKeyDown 轮不到(someProp 按插件序先到先得),
|
||||||
|
// 捕获监听则保证先于一切编辑器内 keydown 处理。
|
||||||
|
view: (editorView) => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
|
||||||
|
if (isImeComposing(event)) return
|
||||||
|
const { state } = editorView
|
||||||
|
const sel = state.selection
|
||||||
|
if (!(sel instanceof NodeSelection) || sel.node.type.name !== 'image') return
|
||||||
|
// createParagraphNear 对行内节点直接返回 false(它只管块级选区),
|
||||||
|
// 自己来:在图片所在的文本块之后插入空段落并把光标放进去。
|
||||||
|
const $pos = state.doc.resolve(sel.from)
|
||||||
|
let depth = $pos.depth
|
||||||
|
while (depth > 0 && !$pos.node(depth).isTextblock) depth--
|
||||||
|
if (depth === 0) return
|
||||||
|
const paraType = state.schema.nodes.paragraph
|
||||||
|
if (!paraType) return
|
||||||
|
const insertAt = $pos.after(depth)
|
||||||
|
const tr = state.tr.insert(insertAt, paraType.create())
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, insertAt + 1))
|
||||||
|
editorView.dispatch(tr.scrollIntoView())
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
editorView.dom.addEventListener('keydown', onKeyDown, true)
|
||||||
|
return {
|
||||||
|
destroy: () => editorView.dom.removeEventListener('keydown', onKeyDown, true),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function createImageDisplayPlugin(resolve: (src: string) => Promise<string | null>) {
|
export function createImageDisplayPlugin(resolve: (src: string) => Promise<string | null>) {
|
||||||
return $prose(() => new Plugin({
|
return $prose(() => new Plugin({
|
||||||
props: {
|
props: {
|
||||||
nodeViews: {
|
nodeViews: {
|
||||||
image: (node) => {
|
image: (node, view, getPos) => {
|
||||||
|
// 包装结构:span.img-wrap(PM 的节点视图 dom)> img + 拖拽手柄。
|
||||||
|
// 手柄在图片被选中时显示(.ProseMirror-selectednode 落在包装上),
|
||||||
|
// 拖动时实时改 img 的 CSS 宽度预览,松手才把宽度提交进节点属性。
|
||||||
|
const wrap = document.createElement('span')
|
||||||
|
wrap.className = 'img-wrap'
|
||||||
const img = document.createElement('img')
|
const img = document.createElement('img')
|
||||||
img.alt = (node.attrs.alt as string) ?? ''
|
img.alt = (node.attrs.alt as string) ?? ''
|
||||||
|
img.draggable = false
|
||||||
if (node.attrs.title) img.title = node.attrs.title as string
|
if (node.attrs.title) img.title = node.attrs.title as string
|
||||||
|
const handle = document.createElement('span')
|
||||||
|
handle.className = 'img-resize-handle'
|
||||||
|
wrap.append(img, handle)
|
||||||
|
let currentNode = node
|
||||||
|
const applyWidth = (w: unknown) => {
|
||||||
|
const css = widthToCss(typeof w === 'string' ? w : null)
|
||||||
|
if (css) img.style.width = css
|
||||||
|
else img.style.removeProperty('width')
|
||||||
|
}
|
||||||
|
applyWidth(node.attrs.width)
|
||||||
let currentSrc = ''
|
let currentSrc = ''
|
||||||
const apply = (src: string) => {
|
const apply = (src: string) => {
|
||||||
currentSrc = src
|
currentSrc = src
|
||||||
@@ -437,11 +496,53 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise<strin
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
apply((node.attrs.src as string) ?? '')
|
apply((node.attrs.src as string) ?? '')
|
||||||
|
|
||||||
|
// PM 用的是 mouse 事件:手柄上的 mousedown/mouseup/click 必须吞掉,
|
||||||
|
// 否则松手时 PM 会按点击位置重设选区,手柄随即消失、没法连续拖。
|
||||||
|
for (const type of ['mousedown', 'mouseup', 'click', 'dblclick'] as const) {
|
||||||
|
handle.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation() })
|
||||||
|
}
|
||||||
|
let drag: { startX: number; startW: number; lastW: number } | null = null
|
||||||
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const w = img.getBoundingClientRect().width
|
||||||
|
drag = { startX: e.clientX, startW: w, lastW: w }
|
||||||
|
handle.setPointerCapture(e.pointerId)
|
||||||
|
})
|
||||||
|
handle.addEventListener('pointermove', (e) => {
|
||||||
|
if (!drag) return
|
||||||
|
const max = wrap.parentElement?.clientWidth || 10000
|
||||||
|
const w = Math.min(Math.max(20, drag.startW + (e.clientX - drag.startX)), Math.max(20, max))
|
||||||
|
drag.lastW = w
|
||||||
|
img.style.width = `${Math.round(w)}px`
|
||||||
|
})
|
||||||
|
const endDrag = () => {
|
||||||
|
if (!drag) return
|
||||||
|
const w = Math.round(drag.lastW)
|
||||||
|
drag = null
|
||||||
|
const pos = typeof getPos === 'function' ? getPos() : undefined
|
||||||
|
if (pos == null || String(w) === String(currentNode.attrs.width ?? '')) {
|
||||||
|
applyWidth(currentNode.attrs.width)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// setNodeMarkup 换掉节点实例后 PM 会按 DOM 选区回读、把节点选区
|
||||||
|
// 塌成文本光标(选区一丢手柄就消失,没法连续拖)——同一事务里把
|
||||||
|
// 节点选区明确设回去。
|
||||||
|
const tr = view.state.tr.setNodeMarkup(pos, undefined, { ...currentNode.attrs, width: String(w) })
|
||||||
|
tr.setSelection(NodeSelection.create(tr.doc, pos))
|
||||||
|
view.dispatch(tr)
|
||||||
|
}
|
||||||
|
handle.addEventListener('pointerup', endDrag)
|
||||||
|
handle.addEventListener('pointercancel', endDrag)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dom: img,
|
dom: wrap,
|
||||||
update: (next) => {
|
update: (next) => {
|
||||||
if (next.type.name !== 'image') return false
|
if (next.type.name !== 'image') return false
|
||||||
|
currentNode = next
|
||||||
img.alt = (next.attrs.alt as string) ?? ''
|
img.alt = (next.attrs.alt as string) ?? ''
|
||||||
|
if (!drag) applyWidth(next.attrs.width)
|
||||||
const src = (next.attrs.src as string) ?? ''
|
const src = (next.attrs.src as string) ?? ''
|
||||||
if (src !== currentSrc) apply(src)
|
if (src !== currentSrc) apply(src)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react'
|
|||||||
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
|
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
|
||||||
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
|
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
|
||||||
* 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
|
* 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
|
||||||
|
* refs 可传多个:菜单经 Portal 渲染到 body 时(如下拉展开层),触发器与
|
||||||
|
* 菜单是两个分离的 DOM 子树,两边都算「内」。
|
||||||
*/
|
*/
|
||||||
export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean, onClose: () => void) {
|
export function useDismiss(
|
||||||
|
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
|
||||||
|
active: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const outside = (target: EventTarget | null) =>
|
const list = Array.isArray(refs) ? refs : [refs]
|
||||||
ref.current && target instanceof Node && !ref.current.contains(target)
|
const outside = (target: EventTarget | null) => {
|
||||||
|
if (!(target instanceof Node)) return false
|
||||||
|
const containers = list.map((r) => r.current).filter(Boolean) as HTMLElement[]
|
||||||
|
return containers.length > 0 && containers.every((el) => !el.contains(target))
|
||||||
|
}
|
||||||
const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
|
const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
const onScroll = (e: Event) => { if (outside(e.target)) onClose() }
|
const onScroll = (e: Event) => { if (outside(e.target)) onClose() }
|
||||||
@@ -26,5 +36,5 @@ export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean,
|
|||||||
document.removeEventListener('scroll', onScroll, true)
|
document.removeEventListener('scroll', onScroll, true)
|
||||||
window.removeEventListener('blur', onClose)
|
window.removeEventListener('blur', onClose)
|
||||||
}
|
}
|
||||||
}, [ref, active, onClose])
|
}, [refs, active, onClose])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,13 +67,14 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
|
|||||||
|
|
||||||
// 图片内嵌
|
// 图片内嵌
|
||||||
if (opts.imageMode === 'embed' && opts.resolveImage) {
|
if (opts.imageMode === 'embed' && opts.resolveImage) {
|
||||||
for (const { node } of images) {
|
// 并发解析(原先逐张串行 IPC,图片多的文档导出线性变慢)
|
||||||
|
await Promise.all(images.map(async ({ node }) => {
|
||||||
const src = node.properties?.src
|
const src = node.properties?.src
|
||||||
if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) {
|
if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) {
|
||||||
const data = await opts.resolveImage(src)
|
const data = await opts.resolveImage?.(src)
|
||||||
if (data) node.properties.src = data
|
if (data) node.properties.src = data
|
||||||
}
|
}
|
||||||
}
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 相对模式按导出文件位置重写图片路径(验收 E3:原先原样保留文档相对路径,
|
// 相对模式按导出文件位置重写图片路径(验收 E3:原先原样保留文档相对路径,
|
||||||
@@ -291,6 +292,9 @@ function loadHighlighter(): Promise<import('shiki').HighlighterCore> {
|
|||||||
})
|
})
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
// 初始化失败时不永久缓存 rejected Promise(否则整会话高亮失效):
|
||||||
|
// 本次照常抛给调用方兜底,下次导出重新尝试初始化
|
||||||
|
highlighterPromise.catch(() => { highlighterPromise = null })
|
||||||
return highlighterPromise
|
return highlighterPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
src/services/gitSync.ts
Normal file
105
src/services/gitSync.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// Git 同步的设置与文案:设置按工作区存进偏好(key=gitsync:<root>),
|
||||||
|
// 定时同步用 localStorage 租约保证多窗口只有一个执行(与草稿认领同一思路)。
|
||||||
|
|
||||||
|
import { dbPrefGet, dbPrefSet, type GitStatusInfo } from './platform'
|
||||||
|
import { pathCompareKey } from '../utils/text'
|
||||||
|
|
||||||
|
export interface GitSyncSettings {
|
||||||
|
/** 定时同步间隔(分钟);0=只手动 */
|
||||||
|
intervalMin: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GIT_SYNC_SETTINGS: GitSyncSettings = { intervalMin: 0 }
|
||||||
|
|
||||||
|
export function parseGitSyncSettings(raw: string | null): GitSyncSettings {
|
||||||
|
if (!raw) return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw) as Partial<GitSyncSettings>
|
||||||
|
const intervalMin = typeof obj.intervalMin === 'number' && obj.intervalMin >= 0 ? obj.intervalMin : 0
|
||||||
|
return { intervalMin }
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGitSyncSettings(root: string): Promise<GitSyncSettings> {
|
||||||
|
try {
|
||||||
|
return parseGitSyncSettings(await dbPrefGet(`gitsync:${pathCompareKey(root)}`))
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGitSyncSettings(root: string, settings: GitSyncSettings): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dbPrefSet(`gitsync:${pathCompareKey(root)}`, JSON.stringify(settings))
|
||||||
|
} catch {
|
||||||
|
// 偏好写入失败不挡同步本身
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 侧栏同步条上的一句状态文案。 */
|
||||||
|
export function gitStatusLabel(status: GitStatusInfo | null, syncing: boolean): string {
|
||||||
|
if (syncing) return '正在同步…'
|
||||||
|
if (!status) return '读取仓库状态…'
|
||||||
|
if (!status.isRepo) return '未启用 Git 同步'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (status.branch) parts.push(status.branch)
|
||||||
|
if (!status.remoteUrl) parts.push('未设置远程')
|
||||||
|
if (status.dirtyCount > 0) parts.push(`待提交 ${status.dirtyCount} 处`)
|
||||||
|
if (status.ahead > 0) parts.push(`待推送 ${status.ahead}`)
|
||||||
|
if (status.behind > 0) parts.push(`待拉取 ${status.behind}`)
|
||||||
|
if (status.dirtyCount === 0 && status.ahead === 0 && status.behind === 0) parts.push('已同步')
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动同步的提交信息(本地时间)。 */
|
||||||
|
export function gitSyncCommitMessage(auto: boolean, now = new Date()): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||||
|
return `${auto ? '自动同步' : '手动同步'} ${stamp}(new-markdown)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 定时同步租约:同一工作区只让一个窗口跑定时同步 ----
|
||||||
|
|
||||||
|
const LEASE_PREFIX = 'nm-gitsync-lease:'
|
||||||
|
const LEASE_TTL_MS = 25_000
|
||||||
|
|
||||||
|
interface Lease {
|
||||||
|
win: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尝试取得/续租该工作区的定时同步租约;被别的窗口新鲜占用时返回 false。 */
|
||||||
|
export function acquireGitSyncLease(root: string, windowId: string, now = Date.now()): boolean {
|
||||||
|
try {
|
||||||
|
const key = LEASE_PREFIX + pathCompareKey(root)
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw) {
|
||||||
|
const lease = JSON.parse(raw) as Lease
|
||||||
|
if (lease.win !== windowId && now - lease.ts < LEASE_TTL_MS) return false
|
||||||
|
}
|
||||||
|
const mine: Lease = { win: windowId, ts: now }
|
||||||
|
localStorage.setItem(key, JSON.stringify(mine))
|
||||||
|
// 回读确认:双窗口同一瞬间都通过检查时,后写者为准,先写者回读会发现
|
||||||
|
// 持有者不是自己,放弃本次(避免并发跑 git 撞 index.lock)
|
||||||
|
try {
|
||||||
|
const back = JSON.parse(localStorage.getItem(key) ?? 'null') as Lease | null
|
||||||
|
return back?.win === windowId
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return true // 存储不可用时不拦(单窗口场景)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseGitSyncLease(root: string, windowId: string): void {
|
||||||
|
try {
|
||||||
|
const key = LEASE_PREFIX + pathCompareKey(root)
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw && (JSON.parse(raw) as Lease).win === windowId) localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -311,3 +311,77 @@ export async function workspaceSearch(root: string, query: string, caseSensitive
|
|||||||
export async function revealInExplorer(path: string): Promise<void> {
|
export async function revealInExplorer(path: string): Promise<void> {
|
||||||
return invoke<void>('reveal_in_explorer', { path })
|
return invoke<void>('reveal_in_explorer', { path })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitStatusInfo {
|
||||||
|
isRepo: boolean
|
||||||
|
branch: string | null
|
||||||
|
remoteUrl: string | null
|
||||||
|
dirtyCount: number
|
||||||
|
ahead: number
|
||||||
|
behind: number
|
||||||
|
hasUpstream: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitSyncOutcome {
|
||||||
|
committed: boolean
|
||||||
|
pushed: boolean
|
||||||
|
pulled: boolean
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作区的 Git 仓库状态(非仓库时 isRepo=false)。 */
|
||||||
|
export async function gitStatus(root: string): Promise<GitStatusInfo> {
|
||||||
|
return invoke<GitStatusInfo>('git_status', { root })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在工作区初始化 Git 仓库并绑定远程(远程可为空)。 */
|
||||||
|
export async function gitInitRepo(root: string, remoteUrl: string, branch: string): Promise<GitStatusInfo> {
|
||||||
|
return invoke<GitStatusInfo>('git_init_repo', { root, remoteUrl, branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/清除工作区仓库的 origin 远程(空字符串=移除)。 */
|
||||||
|
export async function gitSetRemote(root: string, remoteUrl: string): Promise<GitStatusInfo> {
|
||||||
|
return invoke<GitStatusInfo>('git_set_remote', { root, remoteUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步一次:提交本地更改→变基拉取远程→推送。冲突时返回错误且不留半成品。 */
|
||||||
|
export async function gitSyncNow(root: string, message: string): Promise<GitSyncOutcome> {
|
||||||
|
return invoke<GitSyncOutcome>('git_sync_now', { root, message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— S3 图片上传(docs/30):配置随调用传入,Rust 侧签名 + PUT,不保存密钥 ———
|
||||||
|
|
||||||
|
export interface S3ConfigPayload {
|
||||||
|
endpoint: string
|
||||||
|
region: string
|
||||||
|
bucket: string
|
||||||
|
accessKey: string
|
||||||
|
secretKey: string
|
||||||
|
prefix: string
|
||||||
|
publicBase: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传一个本地图片文件,返回应回填文档的公开访问地址。 */
|
||||||
|
export async function s3UploadImage(config: S3ConfigPayload, filePath: string, fileName: string): Promise<string> {
|
||||||
|
return invoke<string>('s3_upload_image', { config, filePath, fileName })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取走待打开文件队列:启动参数/双击第二个文件转来的 .md 路径(docs/32)。 */
|
||||||
|
export async function takePendingOpenFiles(): Promise<string[]> {
|
||||||
|
return invoke<string[]>('take_pending_open_files')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册 .md/.markdown 文件关联并打开系统默认应用设置,返回引导文案(docs/32)。 */
|
||||||
|
export async function registerFileAssociation(): Promise<string> {
|
||||||
|
return invoke<string>('register_file_association')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
|
||||||
|
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
|
||||||
|
return invoke<string>('s3_upload_base64', { config, base64, fileName })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
|
||||||
|
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
|
||||||
|
return invoke<string>('s3_test_connection', { config })
|
||||||
|
}
|
||||||
|
|||||||
121
src/services/s3.ts
Normal file
121
src/services/s3.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// S3 图片上传的前端侧(docs/30):设置形状、完整性判定、本地图片识别与
|
||||||
|
// 路径拼接。真正的签名与 PUT 在 Rust(s3.rs),密钥只随调用传入、不落 Rust 盘。
|
||||||
|
import type { S3ConfigPayload } from './platform'
|
||||||
|
|
||||||
|
export interface S3Settings extends S3ConfigPayload {
|
||||||
|
/** 粘贴/插入图片落盘后自动上传并把引用换成 S3 地址 */
|
||||||
|
autoUpload: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_S3_SETTINGS: S3Settings = {
|
||||||
|
endpoint: '',
|
||||||
|
region: '',
|
||||||
|
bucket: '',
|
||||||
|
accessKey: '',
|
||||||
|
secretKey: '',
|
||||||
|
prefix: '',
|
||||||
|
publicBase: '',
|
||||||
|
autoUpload: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 四项必填齐了才算配置过(endpoint/前缀/访问域名都是可选)。 */
|
||||||
|
export function isS3Configured(s: S3Settings): boolean {
|
||||||
|
return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地图片 = 文档里的相对/本地文件引用;http(s)/blob 已经是网络资源,不需要上传。 */
|
||||||
|
export function isLocalImageSrc(src: string): boolean {
|
||||||
|
const t = src.trim()
|
||||||
|
if (!t) return false
|
||||||
|
return !/^(https?:|data:|blob:)/i.test(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内嵌 base64 图片(data:image/...;base64,...):未落盘文档粘贴的图片、
|
||||||
|
* 导入文档自带的图片都是这种形态——同样算「未上传」,上传时直接传字节,
|
||||||
|
* 不需要本地文件(2026-11-15 用户指正:原先把 data: 一律排除在检测外)。
|
||||||
|
*/
|
||||||
|
export function parseDataImage(src: string): { mime: string; base64: string } | null {
|
||||||
|
const m = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(src.trim())
|
||||||
|
if (!m) return null
|
||||||
|
return { mime: m[1].toLowerCase(), base64: m[2].replace(/\s+/g, '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDataImageSrc(src: string): boolean {
|
||||||
|
return parseDataImage(src) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 需要上传的图片 = 本地文件引用 或 内嵌 base64 图片。 */
|
||||||
|
export function isUploadableImageSrc(src: string): boolean {
|
||||||
|
return isLocalImageSrc(src) || isDataImageSrc(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base64 图片的对象文件名:image-<内容哈希>.<扩展名>。
|
||||||
|
* 用内容哈希(FNV-1a)命名:同一张图重复上传同名覆盖、幂等;不同图不撞名。
|
||||||
|
*/
|
||||||
|
export function dataImageFileName(src: string): string | null {
|
||||||
|
const parsed = parseDataImage(src)
|
||||||
|
if (!parsed) return null
|
||||||
|
let ext = parsed.mime.split('/')[1] ?? 'png'
|
||||||
|
ext = ext.split('+')[0]
|
||||||
|
if (ext === 'jpeg') ext = 'jpg'
|
||||||
|
if (!/^[a-z0-9]+$/.test(ext)) ext = 'png'
|
||||||
|
let h = 0x811c9dc5
|
||||||
|
const b64 = parsed.base64
|
||||||
|
for (let i = 0; i < b64.length; i++) {
|
||||||
|
h ^= b64.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 0x01000193)
|
||||||
|
}
|
||||||
|
return `image-${(h >>> 0).toString(16).padStart(8, '0')}.${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */
|
||||||
|
export function s3ConfigPayload(s: S3Settings): S3ConfigPayload {
|
||||||
|
return {
|
||||||
|
endpoint: s.endpoint,
|
||||||
|
region: s.region,
|
||||||
|
bucket: s.bucket,
|
||||||
|
accessKey: s.accessKey,
|
||||||
|
secretKey: s.secretKey,
|
||||||
|
prefix: s.prefix,
|
||||||
|
publicBase: s.publicBase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把文档相对路径的图片 src 拼成绝对文件路径(供 Rust 读文件)。
|
||||||
|
* 文本级归一:统一 / 分隔、处理 . 与 ..(图片一般在 <文档名>.assets/ 下,
|
||||||
|
* 手写文档也可能用 ../ 引用)。Windows 盘符与正斜杠都兼容。
|
||||||
|
*/
|
||||||
|
export function resolveDocRelativePath(docPath: string, relSrc: string): string {
|
||||||
|
const decoded = safeDecode(relSrc).replace(/\\/g, '/')
|
||||||
|
if (/^[a-zA-Z]:\//.test(decoded) || decoded.startsWith('/')) return decoded
|
||||||
|
const dir = docPath.replace(/\\/g, '/').replace(/\/[^/]*$/, '')
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const seg of `${dir}/${decoded}`.split('/')) {
|
||||||
|
if (seg === '' || seg === '.') {
|
||||||
|
if (seg === '' && parts.length === 0) parts.push('')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (seg === '..') { parts.pop(); continue }
|
||||||
|
parts.push(seg)
|
||||||
|
}
|
||||||
|
return parts.join('/') || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecode(s: string): string {
|
||||||
|
try { return decodeURIComponent(s) } catch { return s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进度/报错里展示的图片名:base64 图用其对象名(哈希名),文件图用原文件名。 */
|
||||||
|
export function imageDisplayName(src: string): string {
|
||||||
|
return dataImageFileName(src) ?? imageFileName(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
|
||||||
|
export function imageFileName(src: string): string {
|
||||||
|
const clean = safeDecode(src).replace(/\\/g, '/')
|
||||||
|
const name = clean.split('/').pop() ?? ''
|
||||||
|
return name || 'image.png'
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
dbPrefGet, dbPrefSet, dbRecoveryList, dbRecoverySave, dbRecoveryDelete,
|
dbPrefGet, dbPrefSet, dbRecoveryList, dbRecoverySave, dbRecoveryDelete,
|
||||||
type RecoveryEntry,
|
type RecoveryEntry,
|
||||||
} from './platform'
|
} from './platform'
|
||||||
|
import { pathsEqual } from '../utils/text'
|
||||||
|
|
||||||
export type { RecoveryEntry }
|
export type { RecoveryEntry }
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export function addRecent(path: string, name: string): void {
|
|||||||
void dbRecentAdd(path, name, Date.now()).catch(() => undefined)
|
void dbRecentAdd(path, name, Date.now()).catch(() => undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const list = readLegacyRecent().filter((r) => r.path !== path)
|
const list = readLegacyRecent().filter((r) => !pathsEqual(r.path, path))
|
||||||
list.unshift({ path, name })
|
list.unshift({ path, name })
|
||||||
localStorage.setItem(LS_RECENT, JSON.stringify(list.slice(0, 10)))
|
localStorage.setItem(LS_RECENT, JSON.stringify(list.slice(0, 10)))
|
||||||
}
|
}
|
||||||
@@ -62,7 +63,7 @@ export function removeRecent(path: string): void {
|
|||||||
void dbRecentRemove(path).catch(() => undefined)
|
void dbRecentRemove(path).catch(() => undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => r.path !== path)))
|
localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => !pathsEqual(r.path, path))))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addWorkspace(path: string): void {
|
export function addWorkspace(path: string): void {
|
||||||
@@ -90,7 +91,9 @@ export async function loadLastWorkspace(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUiPref(): Promise<string | null> {
|
export async function loadUiPref(): Promise<string | null> {
|
||||||
if (!isTauri()) return null
|
if (!isTauri()) {
|
||||||
|
try { return localStorage.getItem(LS_UI) } catch { return null }
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fromDb = await dbPrefGet('ui')
|
const fromDb = await dbPrefGet('ui')
|
||||||
if (fromDb) {
|
if (fromDb) {
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export function createWindowClaims(
|
|||||||
const t = now()
|
const t = now()
|
||||||
const out: Record<string, ClaimEntry> = {}
|
const out: Record<string, ClaimEntry> = {}
|
||||||
for (const [id, entry] of Object.entries(reg)) {
|
for (const [id, entry] of Object.entries(reg)) {
|
||||||
if (entry && typeof entry.ts === 'number' && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry
|
// keys 必须是字符串数组:注册表被写坏(旧版本/外部改动)时整条丢弃,
|
||||||
|
// 避免 liveClaimedKeys 逐字迭代字符串或对不可迭代值抛错
|
||||||
|
const keysOk = Array.isArray(entry?.keys) && entry.keys.every((k) => typeof k === 'string')
|
||||||
|
if (entry && typeof entry.ts === 'number' && keysOk && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -77,4 +80,7 @@ export function createWindowClaims(
|
|||||||
const randomWindowId = `win-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
const randomWindowId = `win-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
/** 本应用窗口的认领单例(App 使用)。 */
|
/** 本应用窗口的认领单例(App 使用)。 */
|
||||||
|
/** 本窗口的随机标识(草稿认领与 Git 定时同步租约共用)。 */
|
||||||
|
export const appWindowId: string = randomWindowId
|
||||||
|
|
||||||
export const appWindowClaims: WindowClaims = createWindowClaims(randomWindowId)
|
export const appWindowClaims: WindowClaims = createWindowClaims(randomWindowId)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export type TabsAction =
|
|||||||
| { type: 'open'; tab: DocTab }
|
| { type: 'open'; tab: DocTab }
|
||||||
| { type: 'activate'; id: string }
|
| { type: 'activate'; id: string }
|
||||||
| { type: 'updateContent'; id: string; content: string }
|
| { type: 'updateContent'; id: string; content: string }
|
||||||
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string }
|
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string; encoding?: string }
|
||||||
| { type: 'syncEditorContent'; id: string; content: string }
|
| { type: 'syncEditorContent'; id: string; content: string }
|
||||||
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
|
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
|
||||||
| { type: 'setPath'; id: string; path: string; title: string }
|
| { type: 'setPath'; id: string; path: string; title: string }
|
||||||
@@ -93,7 +93,7 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
|
|||||||
...state,
|
...state,
|
||||||
tabs: state.tabs.map((t) =>
|
tabs: state.tabs.map((t) =>
|
||||||
t.id === action.id
|
t.id === action.id
|
||||||
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title }
|
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title, encoding: action.encoding ?? t.encoding }
|
||||||
: t,
|
: t,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ button { font: inherit; color: inherit; }
|
|||||||
.sidebar-tabs .sidebar-collapse:hover { color: var(--text); background: var(--hover); }
|
.sidebar-tabs .sidebar-collapse:hover { color: var(--text); background: var(--hover); }
|
||||||
.sidebar-expand { position: absolute; left: 10px; top: 10px; z-index: 35; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 7px; color: var(--text-dim); cursor: pointer; }
|
.sidebar-expand { position: absolute; left: 10px; top: 10px; z-index: 35; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 7px; color: var(--text-dim); cursor: pointer; }
|
||||||
.sidebar-expand:hover { color: var(--text); border-color: var(--accent); }
|
.sidebar-expand:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
.status-upload-btn { background: transparent; border: 1px solid var(--border); color: var(--text-dim); border-radius: 999px; padding: 1px 9px; font-size: 11.5px; font-family: inherit; cursor: pointer; }
|
||||||
|
.status-upload-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
.source-toggle { position: absolute; right: 14px; bottom: 14px; z-index: 35; background: var(--bg-panel); color: var(--text-dim); border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.10); }
|
.source-toggle { position: absolute; right: 14px; bottom: 14px; z-index: 35; background: var(--bg-panel); color: var(--text-dim); border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.10); }
|
||||||
.source-toggle:hover { color: var(--text); border-color: var(--accent); }
|
.source-toggle:hover { color: var(--text); border-color: var(--accent); }
|
||||||
.tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
.tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||||
@@ -159,6 +161,13 @@ button { font: inherit; color: inherit; }
|
|||||||
.modal-actions button { border: 1px solid var(--border); background: var(--bg); border-radius: 7px; padding: 6px 14px; cursor: pointer; }
|
.modal-actions button { border: 1px solid var(--border); background: var(--bg); border-radius: 7px; padding: 6px 14px; cursor: pointer; }
|
||||||
.modal-actions button.primary { background: var(--accent); color: #fff; border-color: transparent; }
|
.modal-actions button.primary { background: var(--accent); color: #fff; border-color: transparent; }
|
||||||
.modal-input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); color: var(--text); }
|
.modal-input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); color: var(--text); }
|
||||||
|
.modal-field-label { display: block; font-size: 12.5px; color: var(--text-dim); margin: 10px 0 4px; }
|
||||||
|
.modal-field-label:first-of-type { margin-top: 0; }
|
||||||
|
.modal-field-row { display: flex; gap: 8px; align-items: stretch; }
|
||||||
|
.modal-field-row .modal-input { flex: 1; }
|
||||||
|
.seg-control { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
|
||||||
|
.seg-control button { padding: 0 12px; background: var(--bg); color: var(--text-dim); border: none; cursor: pointer; font-size: 13px; }
|
||||||
|
.seg-control button.active { background: var(--accent); color: #fff; }
|
||||||
.modal-note { color: var(--text-dim); font-size: 12.5px; }
|
.modal-note { color: var(--text-dim); font-size: 12.5px; }
|
||||||
.shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
.shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||||
.shortcuts-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); }
|
.shortcuts-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); }
|
||||||
@@ -177,6 +186,11 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
|||||||
.milkdown .ProseMirror table { border-collapse: collapse; }
|
.milkdown .ProseMirror table { border-collapse: collapse; }
|
||||||
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
|
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
|
||||||
.milkdown .ProseMirror img { max-width: 100%; }
|
.milkdown .ProseMirror img { max-width: 100%; }
|
||||||
|
.milkdown .ProseMirror img.ProseMirror-selectednode { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
||||||
|
.milkdown .ProseMirror .img-wrap { position: relative; display: inline-block; max-width: 100%; line-height: 0; }
|
||||||
|
.milkdown .ProseMirror .img-wrap.ProseMirror-selectednode { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
||||||
|
.milkdown .ProseMirror .img-wrap .img-resize-handle { position: absolute; right: -6px; bottom: -6px; width: 12px; height: 12px; border-radius: 3px; background: var(--accent); border: 2px solid var(--bg-editor, #fff); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); cursor: nwse-resize; opacity: 0; pointer-events: none; z-index: 3; }
|
||||||
|
.milkdown .ProseMirror .img-wrap.ProseMirror-selectednode .img-resize-handle { opacity: 1; pointer-events: auto; }
|
||||||
.milkdown .ProseMirror a { color: var(--accent); }
|
.milkdown .ProseMirror a { color: var(--accent); }
|
||||||
.milkdown .ProseMirror hr { border: 0; border-top: 1px solid var(--border); }
|
.milkdown .ProseMirror hr { border: 0; border-top: 1px solid var(--border); }
|
||||||
.milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; }
|
.milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; }
|
||||||
@@ -358,7 +372,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
|||||||
.dropdown-button:disabled { opacity: 0.5; cursor: default; }
|
.dropdown-button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.dropdown-button svg { flex: none; color: var(--text-dim); }
|
.dropdown-button svg { flex: none; color: var(--text-dim); }
|
||||||
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.dropdown-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; max-height: 264px; overflow-y: auto; }
|
/* 展开层几何(fixed 定位/宽/最大高)由 Dropdown 组件内联给出:Portal 到 body 逃出弹窗等 overflow 容器的裁剪 */
|
||||||
|
.dropdown-menu { background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow-y: auto; }
|
||||||
.dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; }
|
.dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; }
|
||||||
.dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
|
.dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
|
||||||
.dropdown-menu button.selected { background: var(--accent-weak); }
|
.dropdown-menu button.selected { background: var(--accent-weak); }
|
||||||
@@ -416,3 +431,16 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
|||||||
*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); border: 2px solid transparent; background-clip: content-box; }
|
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); border: 2px solid transparent; background-clip: content-box; }
|
||||||
*::-webkit-scrollbar-corner { background: transparent; }
|
*::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
|
||||||
|
/* Git 同步条(侧栏文件模式顶部) */
|
||||||
|
.gitsync { border-bottom: 1px solid var(--border); padding: 6px 10px; }
|
||||||
|
.gitsync-bar { display: flex; align-items: center; gap: 8px; min-height: 26px; }
|
||||||
|
.gitsync-title { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||||
|
.gitsync-status { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; color: var(--text-dim); }
|
||||||
|
.gitsync-actions { display: flex; gap: 2px; }
|
||||||
|
.gitsync-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: 6px; background: transparent; color: var(--text-dim); cursor: pointer; }
|
||||||
|
.gitsync-btn:hover:not(:disabled) { background: var(--hover); color: var(--text); }
|
||||||
|
.gitsync-btn:disabled { opacity: .5; cursor: default; }
|
||||||
|
.gitsync-msg { margin-top: 4px; font-size: 11.5px; line-height: 1.45; color: var(--text-dim); overflow-wrap: anywhere; }
|
||||||
|
.gitsync-msg.error { color: #cf5b52; }
|
||||||
|
.modal-error { color: #cf5b52; font-size: 12px; line-height: 1.5; margin: 8px 0 0; }
|
||||||
|
|||||||
7
src/utils/menuPos.ts
Normal file
7
src/utils/menuPos.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/** 右键菜单位置夹取:不让菜单溢出窗口右/下边(尺寸为估算值,调用方按条目数给)。 */
|
||||||
|
export function clampMenuPosition(x: number, y: number, width: number, estHeight: number): { left: number; top: number } {
|
||||||
|
return {
|
||||||
|
left: Math.max(4, Math.min(x, window.innerWidth - width - 8)),
|
||||||
|
top: Math.max(4, Math.min(y, window.innerHeight - estHeight - 8)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// 崩溃恢复的纯逻辑(阶段 4):恢复记录的键、启动时该提供/该清理的划分。
|
// 崩溃恢复的纯逻辑(阶段 4):恢复记录的键、启动时该提供/该清理的划分。
|
||||||
import type { RecoveryEntry } from '../services/platform'
|
import type { RecoveryEntry } from '../services/platform'
|
||||||
|
import { pathCompareKey } from './text'
|
||||||
|
|
||||||
export interface RecoveryTabLike {
|
export interface RecoveryTabLike {
|
||||||
id: string
|
id: string
|
||||||
@@ -8,7 +9,7 @@ export interface RecoveryTabLike {
|
|||||||
|
|
||||||
/** 恢复记录的稳定键:文件文档按路径(跨会话一致),未命名文档按标签 id(仅本机延续)。 */
|
/** 恢复记录的稳定键:文件文档按路径(跨会话一致),未命名文档按标签 id(仅本机延续)。 */
|
||||||
export function recoveryKey(tab: RecoveryTabLike): string {
|
export function recoveryKey(tab: RecoveryTabLike): string {
|
||||||
return tab.path ? `path:${tab.path}` : `untitled:${tab.id}`
|
return tab.path ? `path:${pathCompareKey(tab.path)}` : `untitled:${tab.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecoveryPlan {
|
export interface RecoveryPlan {
|
||||||
|
|||||||
@@ -20,14 +20,27 @@ export interface OutlineItem {
|
|||||||
line: number
|
line: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。 */
|
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。
|
||||||
|
* 围栏按字符+长度跟踪:四反引号围栏内的三反引号行不算闭合(docs/29)。 */
|
||||||
export function extractOutline(markdown: string): OutlineItem[] {
|
export function extractOutline(markdown: string): OutlineItem[] {
|
||||||
const items: OutlineItem[] = []
|
const items: OutlineItem[] = []
|
||||||
const lines = markdown.split('\n')
|
const lines = markdown.split('\n')
|
||||||
let inFence = false
|
let fenceChar = ''
|
||||||
|
let fenceLen = 0
|
||||||
lines.forEach((raw, i) => {
|
lines.forEach((raw, i) => {
|
||||||
if (/^\s*(```|~~~)/.test(raw)) inFence = !inFence
|
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(raw)
|
||||||
if (inFence) return
|
if (fence) {
|
||||||
|
const run = fence[1]
|
||||||
|
if (!fenceChar) {
|
||||||
|
fenceChar = run[0]
|
||||||
|
fenceLen = run.length
|
||||||
|
} else if (run[0] === fenceChar && run.length >= fenceLen) {
|
||||||
|
fenceChar = ''
|
||||||
|
fenceLen = 0
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (fenceChar) return
|
||||||
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw)
|
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw)
|
||||||
if (m) items.push({ level: m[1].length, text: m[2].trim(), line: i })
|
if (m) items.push({ level: m[1].length, text: m[2].trim(), line: i })
|
||||||
})
|
})
|
||||||
@@ -48,21 +61,28 @@ export function normalizeSlashes(path: string): string {
|
|||||||
return path.replace(/\\/g, '/')
|
return path.replace(/\\/g, '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归一化后比较两条路径是否相同。 */
|
/** 路径比较键:统一斜杠;Windows 风格路径(盘符/UNC)大小写不敏感,一并折叠小写。 */
|
||||||
|
export function pathCompareKey(path: string): string {
|
||||||
|
const slashed = normalizeSlashes(path).replace(/\/+$/, '')
|
||||||
|
return /^[a-zA-Z]:/.test(slashed) || slashed.startsWith('//') ? slashed.toLowerCase() : slashed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化后比较两条路径是否相同(Windows 路径忽略大小写)。 */
|
||||||
export function pathsEqual(a?: string | null, b?: string | null): boolean {
|
export function pathsEqual(a?: string | null, b?: string | null): boolean {
|
||||||
return Boolean(a && b && normalizeSlashes(a) === normalizeSlashes(b))
|
return Boolean(a && b && pathCompareKey(a) === pathCompareKey(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** child 是否位于 parent 目录之内(不含 parent 本身)。 */
|
/** child 是否位于 parent 目录之内(不含 parent 本身)。 */
|
||||||
export function isPathInside(child: string, parent: string): boolean {
|
export function isPathInside(child: string, parent: string): boolean {
|
||||||
const c = normalizeSlashes(child)
|
return pathCompareKey(child).startsWith(`${pathCompareKey(parent)}/`)
|
||||||
const p = normalizeSlashes(parent).replace(/\/+$/, '')
|
|
||||||
return c.startsWith(`${p}/`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取父目录(保持原分隔符风格)。 */
|
/** 取父目录(保持原分隔符风格);盘符根下的文件返回盘符根(C:/ 而非相对的 C:)。 */
|
||||||
export function parentDirOf(path: string): string {
|
export function parentDirOf(path: string): string {
|
||||||
return path.replace(/[\\/][^\\/]*$/, '')
|
const parent = path.replace(/[\\/][^\\/]*$/, '')
|
||||||
|
if (/^[a-zA-Z]:$/.test(parent)) return `${parent}/`
|
||||||
|
if (parent === '' && path.startsWith('/')) return '/'
|
||||||
|
return parent
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拼接路径(分隔符跟随 dir 的风格)。 */
|
/** 拼接路径(分隔符跟随 dir 的风格)。 */
|
||||||
|
|||||||
@@ -103,3 +103,31 @@ describe('分片载入与整篇解析等价', () => {
|
|||||||
expect(chunked).toBe(whole)
|
expect(chunked).toBe(whole)
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('列表内嵌围栏不被切开(docs/29)', () => {
|
||||||
|
it('分片序列化与整篇逐字相等', async () => {
|
||||||
|
const parts: string[] = []
|
||||||
|
for (let s = 1; s <= 30; s++) {
|
||||||
|
parts.push(`## 小节 ${s}`)
|
||||||
|
parts.push('')
|
||||||
|
parts.push('正文填充。'.repeat(420))
|
||||||
|
parts.push('')
|
||||||
|
parts.push('- 列表项,内含缩进围栏代码:')
|
||||||
|
parts.push('')
|
||||||
|
parts.push(' ```js')
|
||||||
|
parts.push(` const v${s} = ${s}`)
|
||||||
|
parts.push(' ```')
|
||||||
|
parts.push('')
|
||||||
|
parts.push('- 下一项')
|
||||||
|
parts.push('')
|
||||||
|
}
|
||||||
|
const md = parts.join('\n')
|
||||||
|
expect(md.length).toBeGreaterThan(60_000)
|
||||||
|
const whole = getMarkdown(await load(md))
|
||||||
|
const handle = prepareChunkLoad(md)
|
||||||
|
expect(handle).not.toBeNull()
|
||||||
|
const editor = await load(handle!.firstChunk)
|
||||||
|
await handle!.appendRest(editor, () => undefined, () => false)
|
||||||
|
expect(getMarkdown(editor)).toBe(whole)
|
||||||
|
}, 60000)
|
||||||
|
})
|
||||||
|
|||||||
@@ -22,18 +22,44 @@ describe('Dropdown', () => {
|
|||||||
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||||
expect(button.textContent).toContain('GitHub')
|
expect(button.textContent).toContain('GitHub')
|
||||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
const menu = container.querySelector('.dropdown-menu')
|
const menu = document.querySelector('.dropdown-menu')
|
||||||
expect(menu).not.toBeNull()
|
expect(menu).not.toBeNull()
|
||||||
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
|
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
|
||||||
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
|
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
|
||||||
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
expect(onChange).toHaveBeenCalledWith('notion')
|
expect(onChange).toHaveBeenCalledWith('notion')
|
||||||
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
expect(document.querySelector('.dropdown-menu')).toBeNull()
|
||||||
// 再次展开,点别处关闭
|
// 再次展开,点别处关闭
|
||||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
expect(container.querySelector('.dropdown-menu')).not.toBeNull()
|
expect(document.querySelector('.dropdown-menu')).not.toBeNull()
|
||||||
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||||
|
expect(document.querySelector('.dropdown-menu')).toBeNull()
|
||||||
|
await act(async () => { root.unmount() })
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展开层经 Portal 挂到 body;触发钮靠近窗口底部时向上翻且不溢出', async () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const root = createRoot(container)
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Dropdown value="github" options={OPTIONS} onChange={() => undefined} />)
|
||||||
|
})
|
||||||
|
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||||
|
// 模拟触发钮贴近窗口底部(jsdom 默认视口高 768):下方只剩 30px
|
||||||
|
button.getBoundingClientRect = () => ({
|
||||||
|
top: 700, bottom: 730, left: 100, right: 300, width: 200, height: 30, x: 100, y: 700, toJSON: () => ({}),
|
||||||
|
}) as DOMRect
|
||||||
|
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
|
// Portal:菜单不在组件容器内,而是 body 直属
|
||||||
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
||||||
|
const menu = document.querySelector('.dropdown-menu') as HTMLElement | null
|
||||||
|
expect(menu).not.toBeNull()
|
||||||
|
expect(menu!.style.position).toBe('fixed')
|
||||||
|
// 3 项估算高 101,下方放不下 → 翻到触发钮上方:top = 700 - 101 - 4
|
||||||
|
expect(menu!.style.top).toBe('595px')
|
||||||
|
expect(menu!.style.left).toBe('100px')
|
||||||
|
expect(menu!.style.width).toBe('200px')
|
||||||
await act(async () => { root.unmount() })
|
await act(async () => { root.unmount() })
|
||||||
container.remove()
|
container.remove()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ async function renderMenu(props: Record<string, unknown> = {}) {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<EditorContextMenu
|
<EditorContextMenu
|
||||||
x={10} y={10} hasSelection={false} sourceMode={false}
|
x={10} y={10} hasSelection={false} imageSrc={null} sourceMode={false}
|
||||||
onAction={(a) => actions.push(a)}
|
onAction={(a) => actions.push(a)}
|
||||||
onClose={() => { closed += 1 }}
|
onClose={() => { closed += 1 }}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -38,6 +38,34 @@ describe('编辑区右键菜单', () => {
|
|||||||
container.remove()
|
container.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('命中本地图片时多一项「上传到 S3」;网络图片不出现', async () => {
|
||||||
|
const local = await renderMenu({ imageSrc: 'doc.assets/a.png' })
|
||||||
|
const up = btn(local.container, '上传到 S3')
|
||||||
|
expect(up).toBeTruthy()
|
||||||
|
await act(async () => { up?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
|
expect(local.actions).toEqual(['uploadS3'])
|
||||||
|
await act(async () => { local.root.unmount() })
|
||||||
|
local.container.remove()
|
||||||
|
const remote = await renderMenu({ imageSrc: 'https://cdn.example.com/a.png' })
|
||||||
|
expect(btn(remote.container, '上传到 S3')).toBeUndefined()
|
||||||
|
await act(async () => { remote.root.unmount() })
|
||||||
|
remote.container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('命中图片时是精简专用菜单:无格式项/全选/源码模式,有超链接与调整大小', async () => {
|
||||||
|
const { container, root, actions } = await renderMenu({ imageSrc: 'https://cdn.example.com/a.png', hasSelection: true })
|
||||||
|
for (const t of ['加粗', '斜体', '删除线', '行内代码', '全选', '源码模式', '上传到 S3']) {
|
||||||
|
expect(btn(container, t)).toBeUndefined()
|
||||||
|
}
|
||||||
|
for (const t of ['超链接…', '调整图片大小…', '剪切', '复制', '粘贴']) {
|
||||||
|
expect(btn(container, t)).toBeTruthy()
|
||||||
|
}
|
||||||
|
await act(async () => { btn(container, '调整图片大小…')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
|
expect(actions).toEqual(['imageSize'])
|
||||||
|
await act(async () => { root.unmount() })
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
it('源码模式只出编辑命令与「即时渲染」', async () => {
|
it('源码模式只出编辑命令与「即时渲染」', async () => {
|
||||||
const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true })
|
const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true })
|
||||||
expect(btn(container, '加粗')).toBeUndefined()
|
expect(btn(container, '加粗')).toBeUndefined()
|
||||||
|
|||||||
53
tests/editor-images.test.ts
Normal file
53
tests/editor-images.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// 编辑器图片节点操作(docs/30):收集 src、按旧 src 回换、按 DOM 元素反查 src。
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc, imageSrcAtElement } from '../src/editor/editor'
|
||||||
|
|
||||||
|
const DOC = [
|
||||||
|
'# 图',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
async function makeEditor() {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const editor = createMarkdownEditor(root, DOC)
|
||||||
|
await editor.create()
|
||||||
|
return { root, editor }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('editor image ops', () => {
|
||||||
|
it('收集全部图片 src 并去重', async () => {
|
||||||
|
const { root, editor } = await makeEditor()
|
||||||
|
expect(collectImageSrcs(editor)).toEqual(['doc.assets/a.png', 'https://cdn.example.com/b.png'])
|
||||||
|
await editor.destroy()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按旧 src 回换全部同名图片节点(含重复引用)', async () => {
|
||||||
|
const { root, editor } = await makeEditor()
|
||||||
|
const n = replaceImageSrc(editor, 'doc.assets/a.png', 'https://s3.example.com/a.png')
|
||||||
|
expect(n).toBe(2)
|
||||||
|
const md = getMarkdown(editor)
|
||||||
|
expect(md).toContain('https://s3.example.com/a.png')
|
||||||
|
expect(md).not.toContain('doc.assets/a.png')
|
||||||
|
expect(collectImageSrcs(editor)).toEqual(['https://s3.example.com/a.png', 'https://cdn.example.com/b.png'])
|
||||||
|
await editor.destroy()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按 <img> 元素反查节点 src;非图片元素返回 null', async () => {
|
||||||
|
const { root, editor } = await makeEditor()
|
||||||
|
const img = root.querySelector('img')
|
||||||
|
expect(img).not.toBeNull()
|
||||||
|
expect(imageSrcAtElement(editor, img as HTMLElement)).toBe('doc.assets/a.png')
|
||||||
|
expect(imageSrcAtElement(editor, root)).toBeNull()
|
||||||
|
await editor.destroy()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user