Compare commits

..

26 Commits

Author SHA1 Message Date
54d9c45eab chore(release): 版本升级到 0.2.0
补齐 S3 图片上传、图片编辑体验与 .md 文件关联后的重新发布。
2026-11-15 14:19:59 +08:00
Muse
c300b4c8db feat(app): 支持设置 .md 默认打开方式(docs/32)
三条链路:①tauri.conf bundle.fileAssociations 注册 md/markdown,
新版安装包重装后出现在「打开方式」候选;②文件菜单「设为 .md
默认打开方式…」:Rust 命令经 reg.exe 写 HKCU 的 ProgID
NewMarkdown.md/OpenWithProgids/SupportedTypes(免管理员、指向
当前 exe),再打开系统默认应用设置让用户确认一次(Windows 不许
程序直接抢默认);③双击打开:单实例插件转发第二实例参数到待
打开队列+聚焦主窗口,冷启动读 env args,共用 md_files_from_args
过滤;前端 take_pending_open_files 取走逐个打开,只由 main 窗口
消费防子窗口抢文件。命令数 31→33 对账,参数过滤纯函数已抽 crate
实测,vitest 154+2、构建通过;Windows 构建与双击全流程待真机验。
2026-11-15 13:57:31 +08:00
Muse
492051cef0 feat(editor): 图片右键换精简专用菜单(docs/31)
用户要求去掉多余菜单。右键命中图片时只出:超链接…/调整图片
大小…/上传到 S3(未上传图才有)/剪切/复制/粘贴;加粗、斜体、
删除线、行内代码等文本格式项与全选、源码模式对图片没意义,
不再出现(文本右键菜单不变)。配套:右键图片先把它设为节点
选区,描边/手柄随之出现,剪切/复制/Delete 都作用在这张图上。
vitest 153+2、构建通过。
2026-11-15 13:49:19 +08:00
Muse
1c0dd00088 fix(editor): 图片超链接弹窗带出图片地址(docs/31 更正)
上一笔把图片「超链接」做成预填包裹链接,用户指出「没有带出来
这张图片的 url」——要的是图片自己的地址出现在弹窗里。改版
ImageLinkModal:主字段「图片地址」打开即带出该图 src(本地相对
路径或网络地址),可查看可改;包裹链接降为第二字段「点击时打开
的链接(可空)」。提交时一条事务同时处理 src 与 link 标记,
没改动则不发事务。新增弹窗预填与 src patch 测试。
2026-11-15 10:06:47 +08:00
Muse
0c09d94501 feat(editor): 图片可拖拽手柄缩放(docs/31 补)
上一笔只有弹窗输数值,用户追问为何不能拖边缘。NodeView 改包装
结构 span.img-wrap > img + 右下角手柄:图片选中时手柄出现,拖动
实时预览(只改 CSS),松手提交 width 属性(px,夹取 20..段落宽)。
两个坑:手柄的 mouse 事件须吞掉防 PM 按点击位置捣乱;setNodeMarkup
提交后 PM 会回读 DOM 选区把 NodeSelection 塌成文本光标,提交事务
须同带 setSelection 保住选区(updateImageAtElement 同修)。
无头 Chrome 真实拖拽实测:240→+80=320→−30=290,选区全程不丢;
vitest 148+2、构建通过。
2026-11-15 09:48:54 +08:00
Muse
5f4a80fa01 feat(editor): 图片可选中换行、可调大小、右键链接针对图片(docs/31)
用户反馈三件:图片选不中没法在后面打字、右键不能改大小、图片的
超链接是空的。无头 Chrome 真实点击复现根因:点击能形成节点选区
但无任何视觉提示;图片选中时按 Enter 走 splitBlock,新段落插到
图片前面且选区仍停在图片上,接着打字会把图片替换掉。
- Enter 修复:捕获阶段 keydown 只接管图片选区,在图片所在文本块后
  插空段落并移入光标(createParagraphNear 对行内节点无效,自己写);
  选中图片加主题色描边。
- 大小:图片节点加 width 属性,带尺寸时序列化为 <img … width>、
  否则标准语法;remark 插件把孤立 <img> 解析回图片节点(实体解码、
  块级包段落),往返逐字一致;右键「调整图片大小…」弹窗 px/% 可调。
- 链接:右键图片的「超链接…」改为图片模式,预填包裹链接、提交对
  图片节点挂/摘 link 标记([![alt](src)](href) 可往返)。
验证:vitest 148+2(新增 image-size/image-enter 共 12 例)、构建通过、
无头 Chrome 实测点击/Enter/打字全流程。
2026-11-15 09:41:24 +08:00
Muse
fb51276bc5 feat(s3): base64 内嵌图片纳入未上传检测与上传(用户指正)
初版把 data: 一律排除在「未上传」外——未落盘文档粘贴的图、导入
文档自带的图都是 data:image/...;base64 形态,批量/右键/状态栏全
检测不到。现 isUploadableImageSrc = 本地文件引用 ∪ base64 内嵌图:
base64 图走新命令 s3_upload_base64(总数 31 对账),前端剥前缀传
纯 base64、Rust 解码后与文件图共用同一条 PUT 通路;对象名按内容
哈希(FNV-1a)生成,同图同名幂等。未落盘文档粘贴图也挂上自动上传。
进度/报错展示名对 base64 图用其哈希名,不再漏出 base64 串。
验证:独立 crate 对模拟 S3 实测 base64 解码 PUT;vitest 136+2、构建通过。
2026-11-15 09:16:18 +08:00
Muse
b3c22fd597 fix(s3): 自定义访问域名回填地址补上存储桶一段(域名/桶/前缀/文件名)
MinIO 等自建服务 API 地址与访问地址分离,访问域名指向服务根,
原实现按 域名/前缀/文件名 拼、漏了桶名,文档里的地址会 404。
改为 域名/bucket/key;设置弹窗说明同步写清拼接规则,docs/30 补记。
同份 s3.rs 在独立 crate 对模拟 S3 复测通过(期望值已更新)。
2026-11-15 09:03:25 +08:00
Muse
967aada3ca feat(s3): 图片上传到 S3——设置/自动上传/右键单张/批量上传(docs/30)
Rust 新增 s3.rs:SigV4 签名 + PUT Object(reqwest blocking+rustls,
签名对照 AWS 官方示例向量逐字一致;同份代码对本地模拟 S3 端到端
实测通过)。命令 s3_upload_image / s3_test_connection(总数 30 对账)。
配置存 UiState.s3(本机偏好),设置弹窗含测试连接与自动上传开关。
未上传判定 = src 非 http/data/blob 的本地引用;上传成功后同 src 的
图片节点一次事务回换为 S3 地址,本地 .assets 文件保留。
入口:粘贴/插入后自动上传回换(扑空重试一次、失败必提示);右键
命中本地图片多一项「上传到 S3」;文件菜单 + 状态栏批量上传带进度
弹窗。验证:vitest 133+2、tsc、构建通过,设置弹窗截图核对。
2026-11-15 08:38:47 +08:00
03c6c15439 chore(bundle): MSI 安装器切换为简体中文(zh-CN) 2026-11-15 08:20:45 +08:00
8194345667 chore(bundle): NSIS 安装器切换为简体中文界面 2026-11-14 21:22:12 +08:00
499cec2273 chore(git): 忽略 IDE 配置与 vite 时间戳临时文件 2026-11-14 20:36:28 +08:00
Muse
cd64110bf8 chore(git-sync): 仓库地址输入框提示改为 github 示例地址 2026-11-14 20:29:02 +08:00
Muse
eb27e4ca2d fix(dropdown): 展开层改 Portal + fixed 定位,逃出弹窗 overflow 裁剪
弹窗(.modal 带 overflow-y:auto)里的下拉展开层是 absolute 定位,
会被弹窗滚动盒裁掉下半截(Git 同步设置里实测被遮挡)。展开层改经
Portal 挂到 body、按触发钮 rect 用 fixed 定位:下方空间不足时
向上翻,maxHeight 按可用空间夹取;窗口 resize 时关闭。
useDismiss 泛化为可传多个 ref(触发器与 Portal 菜单两棵子树都算内)。
CSS 相应去掉 .dropdown-menu 的 absolute 定位(几何改由内联给出)。
验证:vitest 124+2(新增翻转几何断言:贴底触发钮翻上展开、
Portal 落点不在组件容器内);无头 Chromium 实测弹窗内下拉菜单
完整落在视口内、8 项全显、已逃出弹窗 DOM。
2026-11-14 20:17:51 +08:00
Muse
4fca4b7143 fix(rust): spawn_blocking 改走 tauri::async_runtime(工程无 tokio 依赖,Windows 构建 E0433)
6 处 tokio::task::spawn_blocking(Git 同步 4 处 + docs/29 资源
命令 2 处)全部换成工程既有写法 tauri::async_runtime::spawn_blocking;
全量核对源码外部 crate 前缀与 Cargo.toml 依赖一致。docs/29 补勘误。
2026-11-14 18:09:30 +08:00
Muse
c602573d8e feat(save): 非 UTF-8 文档保存前转码提示(docs/29 D1,用户拍板)
GBK/UTF-16 文档保存会落盘为 UTF-8,原先无任何提示。saveTab 在
落盘前拦截:首次保存弹 EncodingConvertModal 说明将转 UTF-8,
按路径记已确认、本次运行只提示一次;自动保存不弹窗也不静默
转码,跳过等手动保存时提示;确认后以冻结快照 finishSave 收尾,
标签 encoding 如实更新为 UTF-8。弹窗渲染移到弹窗栈最后,避免
被关窗确认框盖住。vitest 123+2、构建通过。
2026-11-14 18:06:02 +08:00
Muse
fb2315a2f3 audit(docs/29): 全项目审计——修分片载入卸载截断高危、Sidebar memo 击穿与 Rust 资源/搜索/恢复上限等 20 余处
三线并行审计(编辑器核心/Rust 后端/服务与组件)+ App 编排层自审。
高危:分片载入未收尾时窗格卸载回写部分序列化,干净标签会被静默
截断——cleanup 补 muted 守卫,create 完成回调补取消守卫。
性能:gitSync/outline/收起回调稳定化保住 Sidebar memo;光标行列
rAF 合并;图片显示解析加缓存;导出图片并发解析;chunkLoader
偏移前缀表;围栏切点只认顶格(列表内嵌围栏等价回归实测)。
Rust:资源命令 async 化 + 32MB 上限;搜索 junction 越界校验;
recovery/workspace 条数与单条上限;启动坏库改名重建 + WAL;
validate 补 ext:: 黑名单与 ref 形态;init 先种 .gitignore
(含 .*.tmp-* 防崩溃残留被提交);游离 HEAD 不再猜分支。
验证:vitest 122+2、tsc、构建通过;Rust 过 rustfmt 解析、
validate 纯函数抽出实跑断言、命令对账 28=28。
2026-11-14 18:00:31 +08:00
Muse
527ca5ecc5 audit(docs/28): Git 同步审计二轮——修复选项注入校验/默认 .gitignore/设置弹窗陈旧守卫,4 低项记录待办 2026-11-14 17:40:09 +08:00
Muse
fadee6aacf feat(git): 工作区 Git 同步——侧栏同步条、远程设置、定时同步(提交→变基拉取→推送,冲突停下不覆盖)(docs/27) 2026-11-14 17:35:41 +08:00
Muse
13271f11b6 fix(files): 未命名保存后刷新文件树;关闭 Tauri 拖放拦截放行侧栏 HTML5 拖拽(docs/25 回修) 2026-11-14 17:08:01 +08:00
Muse
c2958ebf8c fix(tooltip): 气泡改用 :focus-visible 触发,鼠标点过按钮后挪开不再常驻 2026-11-14 17:03:11 +08:00
Muse
fc942a2b88 feat(icons): 应用图标定稿 B 版(米纸底大 M),Tauri 工具生成全套 ico/icns/png,含安装包图标 2026-11-14 16:54:46 +08:00
Muse
a64a8e84bd fix(rust): reveal 命令插入时重复了 rename_path 的 tauri::command 属性(Windows 构建 E0428) 2026-11-14 16:47:08 +08:00
Muse
bfacdd5fbb feat(menus): 编辑区自绘右键菜单 + 全局屏蔽 WebView 自带菜单(docs/26,含 docs/21 勘误) 2026-11-14 16:46:39 +08:00
Muse
bd6fcf3d45 style(search): 区分大小写改行内 [Aa] 开关(搜索按钮前),悬浮提示改自绘主题气泡(Tooltip 组件),撤掉行下胶囊 2026-11-14 16:40:51 +08:00
Muse
c2f69f9303 feat(files): 文件管理四项——侧栏拖拽移动、草稿跨窗口认领、未命名默认存根目录、打开所在位置(docs/25) 2026-11-14 16:36:36 +08:00
89 changed files with 5708 additions and 204 deletions

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ target/
src-tauri/target/ src-tauri/target/
src-tauri/gen/ src-tauri/gen/
*.tsbuildinfo *.tsbuildinfo
.idea/
vite.config.ts.timestamp-*

View File

@@ -1,5 +1,7 @@
# 菜单与切换五项(2026-11-14) # 菜单与切换五项(2026-11-14)
> **勘误(2026-11-14 晚,见 docs/26)**:本文 §1 所称「全局监听 contextmenu 屏蔽 WebView 菜单」当时并未真正落进代码(提交里只有文档与说明文字),编辑区右键一直是浏览器自带菜单。屏蔽与编辑区自绘右键菜单已在 docs/26 补齐。
## 1. 屏蔽浏览器右键菜单 ## 1. 屏蔽浏览器右键菜单
App 全局监听 contextmenu 并 preventDefault:编辑区、标题栏等处不再冒出 App 全局监听 contextmenu 并 preventDefault:编辑区、标题栏等处不再冒出

View File

@@ -0,0 +1,72 @@
# docs/25 文件管理四项:拖拽移动 / 草稿跨窗口 / 未命名默认保存 / 重命名与定位
## 1. 左侧栏拖拽移动
- 文件、文件夹均可拖到任意文件夹上松手完成移动(底层走 rename_path);
拖到顶部根目录标题行 = 移回工作区根目录。
- 投放合法性在拖拽悬停时就判定,不合法的目标不亮、不接受投放:
- **拖回原文件夹**(原位置)→ 不接受(无操作,避免无意义的报错与刷新);
- 拖到自己身上 → 不接受;
- 文件夹拖进自己或自己的子文件夹 → 不接受(App 侧执行时再校验一遍,
并拦截目标已有同名的情况,报错不覆盖)。
- 投放目标高亮(accent 描边+弱底色),被拖条目半透明。
- 移动后已打开的标签路径跟随(含文件夹内的子文档)、最近列表与恢复
记录键统一换位(remapAfterRelocate,改名共用同一套)。
## 2. 新窗口不再重复弹「未保存草稿」
根因:恢复记录在全应用共享的 SQLite 里,每个窗口启动都全量扫描并
按 planRecovery 提示;文档还开在另一个窗口里编辑时,它的自动保存
记录也被当成了崩溃草稿。
修复(services/windowClaims.ts):跨窗口认领注册表(localStorage,
多窗口同源共享)——每个窗口把「打开中的文档键 + 正在提示的草稿键」
登记并 10 秒心跳保鲜、关闭时注销;启动扫描滤掉被其他存活窗口认领
的记录。窗口崩溃残留的认领 45 秒后过期,真正的崩溃草稿不会被吞。
## 3. 未命名文档保存默认落工作区根目录
- 普通保存(Ctrl+S):有工作区时直接写到根目录的「未命名.md」,
同名自动避让为「未命名 1.md / 2.md…」(上限 100,仍撞名退回弹框);
没有工作区才弹保存框(与原来一致)。
- 另存为仍弹框,但默认位置预填到工作区根目录。
## 4. 重命名与打开所在位置
- 重命名本来就有(文件/文件夹右键均有),这次补齐短板:改文件夹名时,
其内已打开的子文档标签、最近列表、恢复记录一并换位(此前只跟随
恰好同路径的单个文件标签,文件夹内的标签会指向失效路径)。
- 右键新增「打开所在位置」:新增 Rust 命令 reveal_in_explorer,
用 explorer /select 打开父目录并选中该文件/文件夹(文件、文件夹
均有此项)。
## 验证
- vitest 106+2(新增:window-claims 3 例、路径助手 2 例、文件树拖放
与定位菜单 5 例);tsc 0 错误;构建通过。
- 无头浏览器实测:应用启动后认领注册表落盘、打开文档的键即时登记,
无页面错误。
- Rust 命令沙箱无工具链未编译(按 commands.rs 既有风格编写),
待 Windows 构建验证。
## 待真机复验
- 拖拽手感(WebView2 下 HTML5 拖放)与各拦截提示;
- 双窗口:窗口 A 编辑未保存 → 开新窗口不再弹草稿;A 崩溃/强杀后
新窗口仍能收到草稿提示;
- 未命名保存直接落根目录、避让命名;
- 「打开所在位置」资源管理器定位效果。
## 真机回修(2026-11-14 晚)
1. **未命名保存后文件树不刷新**:saveTab 只在新建/新建文件夹流程里刷
树,保存落点变化(未命名首次保存、另存为)时没刷。已补:writeTextFile
成功且路径变化时 setTreeRefresh +1。
2. **侧栏拖拽显示禁止符号、drop 不触发**:实现本身(HTML5 DnD)在
DOM 层测试是通过的,真机失效的根因是 Tauri 窗口默认开启
`dragDropEnabled` 原生拖放拦截——它会吞掉 WebView 内的 HTML5 拖拽
事件(本应用没有任何功能在用原生拖放事件)。主窗口配置
(tauri.conf.json)与运行时新窗口(WebviewWindow 参数)均已设
`dragDropEnabled: false`。附带好处:编辑器内 ProseMirror 的文本拖放
同样被拦截所禁,一并恢复。若真机仍不行,下一步改指针式拖拽
(pointer events 自绘),不再依赖 HTML5 DnD。

View File

@@ -0,0 +1,41 @@
# docs/26 编辑区自绘右键菜单 + WebView 自带菜单屏蔽(补账)
## 先说清一笔旧账
docs/21 §1 写了「App 全局监听 contextmenu 并 preventDefault、编辑区
右键已被屏蔽」,但核查提交 2d1cd30 的实际代码后确认:**那段监听从未
落进代码**,只有文档与提交说明写了。结果就是编辑区右键一直是 WebView
自带菜单——用户再次报回。docs/21 已加勘误。本次把屏蔽与自绘菜单一并
真正实现,并用无头浏览器的真实右键逐项验证(见下)。
## 实现
1. **全局屏蔽**(App 挂载 effect):document 监听 contextmenu 并
preventDefault;`input / textarea` 豁免(输入框保留系统菜单的拼写
检查与粘贴)。React 的各自定义菜单(标签/文件树/编辑区)在冒泡链
上先于 document 监听打开,不受影响。
2. **编辑区自绘菜单**(新组件 EditorContextMenu):在 .editor-area 上
按事件落点分发——富文本落在 .ProseMirror、源码落在 .source-cm 才
弹出。条目与顶部「编辑/格式」菜单同一套实现:
- 剪切 / 复制(无选区禁用,选区状态在打开瞬间从编辑器状态读取,
源码模式经 SourceEditorApi 新增的 hasSelection);
- 粘贴(走既有 pasteFromClipboard:富文本经剪贴板读文本插入,
源码聚焦后原生粘贴);全选;
- 富文本独有:加粗 / 斜体 / 删除线 / 行内代码 / 超链接…;
- 末项模式切换(源码模式 ↔ 即时渲染)。
菜单容器 mousedown 阻止默认行为,焦点与选区留在编辑器里,
execCommand 类命令作用准确;位置按窗口尺寸夹取不溢出。
## 验证
- 无头浏览器真实右键:菜单弹出且条目齐全;无选区时剪切/复制禁用、
全选后可用;点「加粗」选中文本真的变粗;标题栏右键无自绘菜单
(自带菜单已被屏蔽);合成事件核查:编辑区 contextmenu 被
preventDefault、输入框放行。
- 组件测试 2 例(tests/editor-context-menu.test.tsx);vitest 108+2;
tsc 0 错误;构建通过。
## 待真机复验
- WebView2 里右键不再出现自带菜单(拼写检查/检查元素等);
- 粘贴在桌面端的剪贴板权限表现(与顶部「编辑 → 粘贴」同一路径)。

53
docs/27-Git同步.md Normal file
View 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
View 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
View 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
View 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,按提示校时即可)。

View 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 保留源码原文,如 `&quot;`)。
- 序列化:有 width 时发 `<img src alt title width>` html 节点(属性
转义),无 width 时仍发标准 `![alt](src)`,普通文档零影响。
- 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 里是包裹式
`[![alt](src)](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
View 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 分支)、安装包关联注册实效、双击冷启动
打开、已运行双击第二个文件转发到主窗口、菜单项注册后在系统
设置里选默认全流程。

BIN
icons-src/icon-a.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

13
icons-src/icon-a.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="gradA" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4A5B74"/>
<stop offset="1" stop-color="#39485E"/>
</linearGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#gradA)"/>
<path d="M320 212 h300 l112 112 v460 a28 28 0 0 1 -28 28 h-384 a28 28 0 0 1 -28 -28 v-544 a28 28 0 0 1 28 -28 z" fill="#F7F3EA"/>
<path d="M620 212 l112 112 h-84 a28 28 0 0 1 -28 -28 z" fill="#DFD8C6"/>
<path d="M392 616 V408 L512 528 L632 408 V616" fill="none" stroke="#39485E" stroke-width="46" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M456 672 L512 726 L568 672" fill="none" stroke="#39485E" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 829 B

BIN
icons-src/icon-b.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

6
icons-src/icon-b.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<rect x="64" y="64" width="896" height="896" rx="200" fill="#F3EEE3"/>
<rect x="64" y="64" width="896" height="896" rx="200" fill="none" stroke="#E2D9C4" stroke-width="10"/>
<path d="M216 716 V308 L512 556 L808 308 V716" fill="none" stroke="#2E3440" stroke-width="94" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M434 786 L512 856 L590 786" fill="none" stroke="#43536A" stroke-width="58" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 530 B

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 B

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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![
@@ -31,6 +99,16 @@ pub fn run() {
commands::create_text_file, commands::create_text_file,
commands::create_dir, commands::create_dir,
commands::rename_path, commands::rename_path,
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
View 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
}

View File

@@ -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
} }

View File

@@ -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(())
} }

View File

@@ -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}]},"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"}

File diff suppressed because it is too large Load Diff

View File

@@ -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>
) )

View File

@@ -0,0 +1,80 @@
import { useRef } from 'react'
import { useDismiss } from '../hooks/useDismiss'
import { clampMenuPosition } from '../utils/menuPos'
import { isUploadableImageSrc } from '../services/s3'
export type EditorMenuAction =
| 'cut' | 'copy' | 'paste' | 'selectAll'
| 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link'
| 'toggleSource' | 'uploadS3' | 'imageSize'
interface Props {
x: number
y: number
/** 当前是否有选区(决定剪切/复制是否可用) */
hasSelection: boolean
/** 右键命中的图片节点 src(非图片为 null);命中图片时换成精简的图片专用菜单 */
imageSrc: string | null
sourceMode: boolean
onAction: (action: EditorMenuAction) => void
onClose: () => void
}
/**
* 编辑区自绘右键菜单(替代 WebView 自带菜单):编辑命令与顶部「编辑/格式」
* 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里,
* execCommand 类命令才作用得准。
* 右键命中图片时只给图片相关项(docs/31):超链接/大小/上传 + 剪切/复制/
* 粘贴;加粗等文本格式项、全选、源码模式对图片没有意义,不再出现。
*/
export default function EditorContextMenu({ x, y, hasSelection, imageSrc, sourceMode, onAction, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null)
useDismiss(ref, true, onClose)
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
const isImage = Boolean(imageSrc)
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与条目数)
const height = isImage ? (showUpload ? 292 : 252) : (sourceMode ? 236 : 388)
const { left, top } = clampMenuPosition(x, y, 188, height)
return (
<div
className="tree-context-menu editor-context-menu"
ref={ref}
style={{ left, top }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
>
{isImage ? (
<>
<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>
)
}

View File

@@ -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)

View File

@@ -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">

View 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>
)
}

View File

@@ -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}>

View 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>
)
}

View File

@@ -1,9 +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 { 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 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
@@ -16,6 +30,10 @@ interface Props {
onCreateFolderIn?: (dir: string) => void onCreateFolderIn?: (dir: string) => void
onRenameEntry?: (entry: DirEntry) => void onRenameEntry?: (entry: DirEntry) => void
onDeleteEntry?: (entry: DirEntry) => void onDeleteEntry?: (entry: DirEntry) => void
/** 拖拽移动:把条目移到目标文件夹 */
onMoveEntry?: (entry: DirEntry, destDir: string) => void
/** 在资源管理器中打开所在位置 */
onRevealEntry?: (entry: DirEntry) => void
/** 文件条目右键的文件类动作(打开/新窗口/另存为/导出/打印) */ /** 文件条目右键的文件类动作(打开/新窗口/另存为/导出/打印) */
onOpenInNewWindowEntry?: (entry: DirEntry) => void onOpenInNewWindowEntry?: (entry: DirEntry) => void
onSaveAsEntry?: (entry: DirEntry) => void onSaveAsEntry?: (entry: DirEntry) => void
@@ -27,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) {
@@ -61,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}
@@ -73,8 +96,11 @@ function Sidebar(p: Props) {
onCreateFolderIn={p.onCreateFolderIn} onCreateFolderIn={p.onCreateFolderIn}
onRenameEntry={p.onRenameEntry} onRenameEntry={p.onRenameEntry}
onDeleteEntry={p.onDeleteEntry} onDeleteEntry={p.onDeleteEntry}
onMoveEntry={p.onMoveEntry}
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>
@@ -87,7 +113,44 @@ function Sidebar(p: Props) {
) )
} }
function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry }: { interface TreeDnd {
draggingPath: string | null
dropDir: string | null
canDrop: (destDir: string) => boolean
start: (entry: DirEntry, dataTransfer: DataTransfer) => void
end: () => void
over: (dir: string) => void
leave: (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 }: {
root: string root: string
activePath: string | null activePath: string | null
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
@@ -100,8 +163,38 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
onSaveAsEntry?: (entry: DirEntry) => void onSaveAsEntry?: (entry: DirEntry) => void
onExportEntry?: (entry: DirEntry) => void onExportEntry?: (entry: DirEntry) => void
onPrintEntry?: (entry: DirEntry) => void onPrintEntry?: (entry: DirEntry) => void
onMoveEntry?: (entry: DirEntry, destDir: string) => void
onRevealEntry?: (entry: DirEntry) => void
}) { }) {
const [refreshKey, setRefreshKey] = useState(0) const [refreshKey, setRefreshKey] = useState(0)
const [dragEntry, setDragEntry] = useState<DirEntry | null>(null)
const [dropDir, setDropDir] = useState<string | null>(null)
// 拖放合法性:不许拖回原文件夹(无操作)、不许拖到自己身上、
// 文件夹不许拖进自己或自己的子文件夹
const canDropOn = useCallback((entry: DirEntry, destDir: string) => {
if (pathsEqual(parentDirOf(entry.path), destDir)) return false
if (pathsEqual(entry.path, destDir)) return false
if (entry.isDir && isPathInside(destDir, entry.path)) return false
return true
}, [])
const dnd: TreeDnd | undefined = onMoveEntry ? {
draggingPath: dragEntry?.path ?? null,
dropDir,
canDrop: (destDir) => dragEntry !== null && canDropOn(dragEntry, destDir),
start: (entry, dataTransfer) => {
dataTransfer.effectAllowed = 'move'
dataTransfer.setData('text/plain', entry.path)
setDragEntry(entry)
},
end: () => { setDragEntry(null); setDropDir(null) },
over: (dir) => setDropDir(dir),
leave: (dir) => setDropDir((cur) => (cur === dir ? null : cur)),
drop: (destDir) => {
if (dragEntry && canDropOn(dragEntry, destDir)) onMoveEntry(dragEntry, destDir)
setDragEntry(null)
setDropDir(null)
},
} : undefined
const [menu, setMenu] = useState<{ x: number; y: number; entry: DirEntry } | null>(null) const [menu, setMenu] = useState<{ x: number; y: number; entry: DirEntry } | null>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => setMenu(null), []) const closeMenu = useCallback(() => setMenu(null), [])
@@ -109,7 +202,12 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
useEffect(() => { if (refreshSignal > 0) setRefreshKey((k) => k + 1) }, [refreshSignal]) useEffect(() => { if (refreshSignal > 0) setRefreshKey((k) => k + 1) }, [refreshSignal])
return ( return (
<div className="filetree"> <div className="filetree">
<div className="filetree-head"> <div
className={`filetree-head${dnd && dropDir === root ? ' drop-target' : ''}`}
onDragOver={dnd ? (e) => { if (dnd.canDrop(root)) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; dnd.over(root) } } : undefined}
onDragLeave={dnd ? () => dnd.leave(root) : undefined}
onDrop={dnd ? (e) => { e.preventDefault(); dnd.drop(root) } : undefined}
>
<span className="filetree-root" title={root}>{baseName(root)}</span> <span className="filetree-root" title={root}>{baseName(root)}</span>
{onCreateFileIn && <button className="filetree-add" title="在工作区根目录新建文档" onClick={() => onCreateFileIn(root)}>+ 文档</button>} {onCreateFileIn && <button className="filetree-add" title="在工作区根目录新建文档" onClick={() => onCreateFileIn(root)}>+ 文档</button>}
{onCreateFolderIn && <button className="filetree-add" title="在工作区根目录新建文件夹" onClick={() => onCreateFolderIn(root)}>+ 文件夹</button>} {onCreateFolderIn && <button className="filetree-add" title="在工作区根目录新建文件夹" onClick={() => onCreateFolderIn(root)}>+ 文件夹</button>}
@@ -118,11 +216,13 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
<TreeLevel <TreeLevel
dir={root} depth={0} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} dir={root} depth={0} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey}
onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })} onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })}
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>}
{!menu.entry.isDir && onSaveAsEntry && <button onClick={() => { onSaveAsEntry(menu.entry); setMenu(null) }}>另存为…</button>} {!menu.entry.isDir && onSaveAsEntry && <button onClick={() => { onSaveAsEntry(menu.entry); setMenu(null) }}>另存为…</button>}
{!menu.entry.isDir && onExportEntry && <button onClick={() => { onExportEntry(menu.entry); setMenu(null) }}>导出…</button>} {!menu.entry.isDir && onExportEntry && <button onClick={() => { onExportEntry(menu.entry); setMenu(null) }}>导出…</button>}
{!menu.entry.isDir && onPrintEntry && <button onClick={() => { onPrintEntry(menu.entry); setMenu(null) }}>打印 / 导出 PDF…</button>} {!menu.entry.isDir && onPrintEntry && <button onClick={() => { onPrintEntry(menu.entry); setMenu(null) }}>打印 / 导出 PDF…</button>}
@@ -137,13 +237,14 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
) )
} }
function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu }: { function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu, dnd }: {
dir: string dir: string
depth: number depth: number
activePath: string | null activePath: string | null
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
refreshKey: number refreshKey: number
onEntryMenu: (entry: DirEntry, x: number, y: number) => void onEntryMenu: (entry: DirEntry, x: number, y: number) => void
dnd?: TreeDnd
}) { }) {
const [entries, setEntries] = useState<DirEntry[] | null>(null) const [entries, setEntries] = useState<DirEntry[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -178,17 +279,23 @@ 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' : ''}`} 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) }}
title={entry.path} title={entry.path}
draggable={Boolean(dnd)}
onDragStart={dnd ? (e) => dnd.start(entry, e.dataTransfer) : undefined}
onDragEnd={dnd ? () => dnd.end() : undefined}
onDragOver={dnd && entry.isDir ? (e) => { if (dnd.canDrop(entry.path)) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; dnd.over(entry.path) } } : undefined}
onDragLeave={dnd && entry.isDir ? () => dnd.leave(entry.path) : undefined}
onDrop={dnd && entry.isDir ? (e) => { e.preventDefault(); dnd.drop(entry.path) } : undefined}
> >
<span className="tree-icon">{entry.isDir ? (expanded.has(entry.path) ? '▾' : '▸') : '·'}</span> <span className="tree-icon">{entry.isDir ? (expanded.has(entry.path) ? '▾' : '▸') : '·'}</span>
{entry.name} {entry.name}
</button> </button>
{entry.isDir && expanded.has(entry.path) && ( {entry.isDir && expanded.has(entry.path) && (
<TreeLevel dir={entry.path} depth={depth + 1} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} onEntryMenu={onEntryMenu} /> <TreeLevel dir={entry.path} depth={depth + 1} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} onEntryMenu={onEntryMenu} dnd={dnd} />
)} )}
</div> </div>
))} ))}
@@ -208,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)
} }
} }
@@ -244,28 +355,30 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void run() }} onKeyDown={(e) => { if (e.key === 'Enter') void run() }}
/> />
<button <Tooltip label="区分大小写">
className="ws-search-go" <button
disabled={searching || !query.trim()} className={`ws-search-aa${caseSensitive ? ' on' : ''}`}
onClick={() => void run()} aria-pressed={caseSensitive}
title="搜索(Enter)" aria-label="区分大小写"
aria-label="搜索" onClick={() => setCaseSensitive((v) => !v)}
> >
{searching ? ( Aa
<span className="ws-spin" aria-hidden="true" /> </button>
) : ( </Tooltip>
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" strokeWidth="1.8" /><path d="M20.5 20.5 16.7 16.7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg> <Tooltip label="搜索(Enter)">
)} <button
</button> className="ws-search-go"
</div> disabled={searching || !query.trim()}
<div className="ws-search-opts"> onClick={() => void run()}
<button aria-label="搜索"
className={`chip-toggle${caseSensitive ? ' on' : ''}`} >
aria-pressed={caseSensitive} {searching ? (
onClick={() => setCaseSensitive((v) => !v)} <span className="ws-spin" aria-hidden="true" />
> ) : (
区分大小写 <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" strokeWidth="1.8" /><path d="M20.5 20.5 16.7 16.7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
</button> )}
</button>
</Tooltip>
</div> </div>
{error && <div className="sidebar-empty">{error}</div>} {error && <div className="sidebar-empty">{error}</div>}
{results && ( {results && (

Some files were not shown because too many files have changed in this diff Show More