9 Commits
v0.1.0 ... main

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
31 changed files with 3184 additions and 59 deletions

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 分支)、安装包关联注册实效、双击冷启动
打开、已运行双击第二个文件转发到主窗口、菜单项注册后在系统
设置里选默认全流程。

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"

View File

@@ -925,3 +925,106 @@ pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcom
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} }
// ————————————————— S3 图片上传(docs/30) —————————————————
// 配置随调用从前端传入(设置弹窗收集、存本机偏好),Rust 侧不保存密钥;
// 实现在 s3.rs(SigV4 签名已对照 AWS 官方示例向量验证)。
#[tauri::command]
pub async fn s3_upload_image(
config: crate::s3::S3Config,
file_path: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || crate::s3::upload(&config, &file_path, &file_name))
.await
.map_err(|e| format!("上传任务失败:{e}"))?
}
/// 取走待打开文件队列(docs/32):启动参数/第二实例双击转来的 .md 文件。
#[tauri::command]
pub fn take_pending_open_files(state: tauri::State<'_, crate::PendingOpen>) -> Vec<String> {
match state.0.lock() {
Ok(mut queue) => std::mem::take(&mut *queue),
Err(_) => Vec::new(),
}
}
/// 把本程序注册为 .md/.markdown 的候选打开方式(docs/32),并打开 Windows
/// 「默认应用」设置页让用户确认。Windows 不允许程序直接抢占默认(UserChoice
/// 带校验),应用能做的是注册候选 + 引导用户点一次。
#[tauri::command]
pub async fn register_file_association() -> Result<String, String> {
tauri::async_runtime::spawn_blocking(register_file_association_impl)
.await
.map_err(|e| format!("注册任务失败:{e}"))?
}
#[cfg(not(windows))]
fn register_file_association_impl() -> Result<String, String> {
Err("文件关联设置仅支持 Windows。".into())
}
#[cfg(windows)]
fn register_file_association_impl() -> Result<String, String> {
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let exe = std::env::current_exe().map_err(|e| format!("无法定位程序位置:{e}"))?;
let exe_s = exe.to_string_lossy().to_string();
let exe_name = exe
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "new-markdown.exe".into());
let prog_id = "NewMarkdown.md";
let open_cmd = format!("\"{exe_s}\" \"%1\"");
let mut failures: Vec<String> = Vec::new();
let mut reg = |args: &[&str]| {
match Command::new("reg.exe")
.args(args)
.creation_flags(CREATE_NO_WINDOW)
.status()
{
Ok(st) if st.success() => {}
Ok(st) => failures.push(format!("reg {:?} 退出码 {}", args.get(1), st.code().unwrap_or(-1))),
Err(e) => failures.push(format!("reg 执行失败:{e}")),
}
};
// ProgID:类型名、打开命令、图标
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}"), "/ve", "/d", "Markdown 文档", "/f"]);
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]);
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\DefaultIcon"), "/ve", "/d", &format!("\"{exe_s}\",0"), "/f"]);
for ext in [".md", ".markdown"] {
// 扩展名的候选列表 + 应用的 SupportedTypes(出现在「打开方式」里)
reg(&["add", &format!(r"HKCU\Software\Classes\{ext}\OpenWithProgids"), "/v", prog_id, "/t", "REG_SZ", "/d", "", "/f"]);
reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\SupportedTypes"), "/v", ext, "/t", "REG_SZ", "/d", "", "/f"]);
}
reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]);
if !failures.is_empty() {
return Err(format!("注册表写入失败:{}", failures.join(";")));
}
// 打开系统「默认应用」设置,用户在那里把 .md 选成本程序(只需一次)
let _ = Command::new("explorer.exe")
.arg("ms-settings:defaultapps")
.creation_flags(CREATE_NO_WINDOW)
.spawn();
Ok("已把 new-markdown 注册为 .md / .markdown 的候选打开方式,并打开了 Windows「默认应用」设置。请在设置里找到 .md,把默认应用选为 new-markdown(Windows 要求手动确认一次)。".into())
}
#[tauri::command]
pub async fn s3_upload_base64(
config: crate::s3::S3Config,
base64: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || crate::s3::upload_base64(&config, &base64, &file_name))
.await
.map_err(|e| format!("上传任务失败:{e}"))?
}
#[tauri::command]
pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))
.await
.map_err(|e| format!("连接测试失败:{e}"))?
}

View File

@@ -1,13 +1,31 @@
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 让多开实例的写操作排队而非直接报错。 /// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。
fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String> { 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 = rusqlite::Connection::open(path).map_err(|e| e.to_string())?;
@@ -21,6 +39,23 @@ fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String>
#[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");
@@ -49,6 +84,9 @@ pub fn run() {
} }
}; };
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![
@@ -66,6 +104,11 @@ pub fn run() {
commands::git_init_repo, commands::git_init_repo,
commands::git_set_remote, commands::git_set_remote,
commands::git_sync_now, 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

@@ -1 +1 @@
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","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"]},"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"}

View File

@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync' import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
import { insertPlainText, insertLinkText } from './editor/paste' import { insertPlainText, insertLinkText } from './editor/paste'
import { isImeComposing } from './utils/ime' import { isImeComposing } from './utils/ime'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal } from './components/Modals' import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal, InfoModal } from './components/Modals'
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs' import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text' import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor' import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
@@ -29,13 +29,15 @@ import {
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry, statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, type GitStatusInfo, gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, takePendingOpenFiles, registerFileAssociation, type GitStatusInfo,
} from './services/platform' } from './services/platform'
import * as store from './services/store' import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery' import { recoveryKey, planRecovery } from './utils/recovery'
import { appWindowClaims, appWindowId } from './services/windowClaims' import { appWindowClaims, appWindowId } from './services/windowClaims'
import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync' import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync'
import { GitSyncModal } from './components/GitSyncModal' import { GitSyncModal } from './components/GitSyncModal'
import { S3SettingsModal } from './components/S3SettingsModal'
import { DEFAULT_S3_SETTINGS, isS3Configured, isUploadableImageSrc, isDataImageSrc, parseDataImage, dataImageFileName, resolveDocRelativePath, imageFileName, imageDisplayName, s3ConfigPayload, type S3Settings } from './services/s3'
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals' import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
import { AppearanceModal, ExportModal } from './components/Stage5Modals' import { AppearanceModal, ExportModal } from './components/Stage5Modals'
import { DOC_THEMES, docThemeById } from './themes' import { DOC_THEMES, docThemeById } from './themes'
@@ -61,11 +63,13 @@ const UI_KEY = 'new-markdown:ui'
function loadRecent(): string[] { function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] } try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
} }
interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record<string, string> } interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record<string, string>; s3: S3Settings }
function loadUi(): UiState { function loadUi(): UiState {
const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {} } const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {}, s3: DEFAULT_S3_SETTINGS }
try { try {
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') } const parsed = JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') as Partial<UiState>
if (parsed.s3) parsed.s3 = { ...DEFAULT_S3_SETTINGS, ...parsed.s3 }
return { ...defaults, ...parsed }
} catch { return defaults } } catch { return defaults }
} }
@@ -667,7 +671,11 @@ export default function App() {
// 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。 // 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。
const uiJson = await store.loadUiPref() const uiJson = await store.loadUiPref()
if (!cancelled && uiJson) { if (!cancelled && uiJson) {
try { setUi((prev) => ({ ...prev, ...JSON.parse(uiJson) })) } catch { /* 偏好损坏时用默认值 */ } try {
const parsed = JSON.parse(uiJson) as Partial<UiState>
if (parsed.s3) parsed.s3 = { ...DEFAULT_S3_SETTINGS, ...parsed.s3 }
setUi((prev) => ({ ...prev, ...parsed }))
} catch { /* 偏好损坏时用默认值 */ }
} }
uiLoadedRef.current = true uiLoadedRef.current = true
const recentFromDb = await store.loadRecent() const recentFromDb = await store.loadRecent()
@@ -700,11 +708,47 @@ export default function App() {
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。 // 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
const startupFile = new URLSearchParams(window.location.search).get('file') const startupFile = new URLSearchParams(window.location.search).get('file')
if (!cancelled && startupFile) void openPath(startupFile) if (!cancelled && startupFile) void openPath(startupFile)
// 双击 .md 冷启动:文件路径经启动参数进 Rust 的待打开队列(docs/32)。
// 只由主窗口消费——子窗口(?file= 打开的)也在跑同一套启动流程,
// 不拦会把队列里的文件抢去自己窗口打开。
if (!cancelled && tauriMode) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
if (getCurrentWindow().label === 'main') {
const pending = await takePendingOpenFiles()
for (const p of pending) { if (!cancelled) await openPath(p) }
}
} catch { /* 队列不可用时不影响正常启动 */ }
}
})() })()
return () => { cancelled = true } return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// 第二个文件双击转来时(单实例转发):Rust 发事件,这里取队列打开(docs/32)
useEffect(() => {
if (!tauriMode) return
let unlisten: (() => void) | undefined
let cancelled = false
void (async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
if (getCurrentWindow().label !== 'main') return // 只由主窗口消费(理由同冷启动)
const { listen } = await import('@tauri-apps/api/event')
const un = await listen('pending-open-files', async () => {
try {
const pending = await takePendingOpenFiles()
for (const p of pending) await openPath(p)
} catch { /* 同上 */ }
})
if (cancelled) un()
else unlisten = un
} catch { /* 事件不可用时退化为仅冷启动打开 */ }
})()
return () => { cancelled = true; unlisten?.() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tauriMode])
useEffect(() => { useEffect(() => {
if (workspaceRoot) { if (workspaceRoot) {
store.addWorkspace(workspaceRoot) store.addWorkspace(workspaceRoot)
@@ -1078,7 +1122,12 @@ export default function App() {
}, []) }, [])
// ——— 编辑区自绘右键菜单 ——— // ——— 编辑区自绘右键菜单 ———
const [editorMenu, setEditorMenu] = useState<{ x: number; y: number; hasSelection: boolean } | null>(null) const [editorMenu, setEditorMenu] = useState<{ x: number; y: number; hasSelection: boolean; imageSrc: string | null; imageEl: HTMLElement | null; tabId: string | null } | null>(null)
/** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */
const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; src: string; href: string } | null>(null)
const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null)
/** 文件关联注册后的引导文案(docs/32) */
const [assocMsg, setAssocMsg] = useState<string | null>(null)
const openEditorMenu = (e: React.MouseEvent) => { const openEditorMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
const inEditor = sourceMode const inEditor = sourceMode
@@ -1087,15 +1136,31 @@ export default function App() {
if (!inEditor) return if (!inEditor) return
e.preventDefault() e.preventDefault()
let hasSelection = false let hasSelection = false
let imageSrc: string | null = null
let imageEl: HTMLElement | null = null
let imageTabId: string | null = null
if (sourceMode) { if (sourceMode) {
hasSelection = sourceApiRef.current?.hasSelection() ?? false hasSelection = sourceApiRef.current?.hasSelection() ?? false
} else if (tab) { } else if (tab) {
const editor = paneApisRef.current.get(tab.id)?.editor const api = paneApisRef.current.get(tab.id)
if (editor) { if (api?.editor) {
try { hasSelection = editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false } try { hasSelection = api.editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false }
}
// 右键命中图片时取出它的文档 src:本地未上传图会在菜单里多一项「上传到 S3」;
// 同时记住图片元素与标签,大小/链接编辑要按元素定位回节点
const img = target.closest('img')
if (img && api) {
imageSrc = api.imageSrcForElement(img)
if (imageSrc) {
imageEl = img
imageTabId = tab.id
// 右键图片先把它选中:描边/手柄随之出现,菜单里的剪切/复制与
// 之后的 Delete 都作用在这张图上(与常见编辑器行为一致)
if (api.selectImageForElement(img)) hasSelection = true
}
} }
} }
setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection }) setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection, imageSrc, imageEl, tabId: imageTabId })
} }
const runEditorMenuAction = (action: EditorMenuAction) => { const runEditorMenuAction = (action: EditorMenuAction) => {
switch (action) { switch (action) {
@@ -1107,8 +1172,28 @@ export default function App() {
case 'italic': withEditor(editorCommands.italic); break case 'italic': withEditor(editorCommands.italic); break
case 'strikethrough': withEditor(editorCommands.strikethrough); break case 'strikethrough': withEditor(editorCommands.strikethrough); break
case 'inlineCode': withEditor(editorCommands.inlineCode); break case 'inlineCode': withEditor(editorCommands.inlineCode); break
case 'link': setModal('link'); break case 'link': {
// 右键的是图片:链接弹窗切图片模式——预填图片当前的包裹链接,
// 提交后把链接标记挂到图片节点上(序列化为 [![alt](src)](href))
const el = editorMenu?.imageEl
const tid = editorMenu?.tabId
if (el && tid) {
const info = paneApisRef.current.get(tid)?.imageInfoForElement(el)
setImageLinkTarget({ tabId: tid, el, src: info?.src ?? '', href: info?.linkHref ?? '' })
} else setModal('link')
break
}
case 'imageSize': {
const el = editorMenu?.imageEl
const tid = editorMenu?.tabId
if (el && tid) {
const info = paneApisRef.current.get(tid)?.imageInfoForElement(el)
setImageSizeTarget({ tabId: tid, el, width: info?.width ?? null })
}
break
}
case 'toggleSource': toggleSourceMode(); break case 'toggleSource': toggleSourceMode(); break
case 'uploadS3': { const src = editorMenu?.imageSrc; if (src) void runSingleImageUpload(src); break }
} }
} }
@@ -1128,6 +1213,8 @@ export default function App() {
const stateRef = useRef(state) const stateRef = useRef(state)
stateRef.current = state stateRef.current = state
const uiRef = useRef(ui)
uiRef.current = ui
// 活动标签变化时登记保活顺序(最近用过的排前面,最多 6 个;超出的卸载走最终回写同步) // 活动标签变化时登记保活顺序(最近用过的排前面,最多 6 个;超出的卸载走最终回写同步)
const activeIdForAlive = tab?.id const activeIdForAlive = tab?.id
@@ -1155,21 +1242,114 @@ export default function App() {
} }
}, [activeIdForAlive]) }, [activeIdForAlive])
// ——— S3 图片上传(docs/30) ———
const [s3SettingsOpen, setS3SettingsOpen] = useState(false)
const [s3Progress, setS3Progress] = useState<{ total: number; done: number; current: string; errors: string[]; finished: boolean } | null>(null)
const s3BusyRef = useRef(false)
/**
* 上传一张未上传图片,返回 S3 公开地址:base64 内嵌图直接传字节(不需
* 文档落盘);本地文件图按 docPath + 相对 src 定位文件。
*/
const uploadImageSrc = useCallback(async (docPath: string | null, src: string): Promise<string> => {
const cfg = s3ConfigPayload(uiRef.current.s3)
const data = parseDataImage(src)
if (data) {
const name = dataImageFileName(src)
if (!name) throw new Error('无法识别内嵌图片格式')
return s3UploadBase64(cfg, data.base64, name)
}
if (!docPath) throw new Error('文档尚未保存,无法定位本地图片文件')
return s3UploadImage(cfg, resolveDocRelativePath(docPath, src), imageFileName(src))
}, [])
/** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */
const runSingleImageUpload = useCallback(async (src: string) => {
const current = activeTab(stateRef.current)
if (!current) return
if (!isDataImageSrc(src) && !current.path) { setError('请先保存文档,再上传本地图片。'); return }
if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
flash('正在上传图片…')
try {
const url = await uploadImageSrc(current.path, src)
paneApisRef.current.get(current.id)?.replaceImageSrc(src, url)
flash('图片已上传到 S3,引用已更新')
} catch (e) { setError(e instanceof Error ? e.message : String(e)) }
}, [flash, uploadImageSrc])
/** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */
const runBatchImageUpload = async () => {
if (!tab) return
if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return }
if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
if (s3BusyRef.current) return
const api = paneApisRef.current.get(tab.id)
if (!api) return
const locals = [...new Set(api.listImages().filter(isUploadableImageSrc))]
if (locals.length === 0) { flash('本文档没有未上传的图片'); return }
const docPath = tab.path
s3BusyRef.current = true
setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false })
const errors: string[] = []
let done = 0
for (const src of locals) {
setS3Progress((pr) => (pr ? { ...pr, current: imageDisplayName(src) } : pr))
try {
const url = await uploadImageSrc(docPath, src)
api.replaceImageSrc(src, url)
} catch (e) {
errors.push(`${imageDisplayName(src)}:${e instanceof Error ? e.message : String(e)}`)
}
done += 1
setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr))
}
s3BusyRef.current = false
setS3Progress((pr) => (pr ? { ...pr, current: '', errors, finished: true } : pr))
}
/**
* 自动上传(设置开关):图片落盘后在后台上传,成功后把引用换成 S3 地址。
* 粘贴建节点是异步的,第一次替换可能扑空,900ms 后重试一次;仍扑空则
* 保留本地引用并提示可用批量上传同步,不静默。
*/
const autoUploadSwap = useCallback((tabId: string, docPath: string | null, relSrc: string) => {
const settings = uiRef.current.s3
if (!isTauri() || !settings.autoUpload || !isS3Configured(settings)) return
void (async () => {
try {
const url = await uploadImageSrc(docPath, relSrc)
let n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0
if (n === 0) {
await new Promise((r) => setTimeout(r, 900))
n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0
}
if (n === 0) flash('图片已上传到 S3,但引用替换未生效,可用「上传文档图片到 S3」再同步一次')
} catch (e) {
flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`)
}
})()
}, [flash, uploadImageSrc])
/** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */ /** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */
const handleImageFile = useCallback(async (file: File): Promise<string> => { const handleImageFile = useCallback(async (file: File): Promise<string> => {
const current = activeTab(stateRef.current) const current = activeTab(stateRef.current)
if (isTauri() && current?.path) { if (isTauri() && current?.path) {
const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg') const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg')
const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}` const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}`
return saveBytesToAssets(current.path, named, await fileToBase64(file)) const rel = await saveBytesToAssets(current.path, named, await fileToBase64(file))
autoUploadSwap(current.id, current.path, rel)
return rel
} }
return new Promise((resolve) => { const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => resolve(String(reader.result)) reader.onload = () => resolve(String(reader.result))
reader.onerror = () => resolve(URL.createObjectURL(file)) reader.onerror = () => resolve(URL.createObjectURL(file))
reader.readAsDataURL(file) reader.readAsDataURL(file)
}) })
}, []) // 未落盘文档的粘贴图是 base64 内嵌形态:开了自动上传同样传走并回换
if (current && dataUrl.startsWith('data:')) autoUploadSwap(current.id, current.path ?? null, dataUrl)
return dataUrl
}, [autoUploadSwap])
const insertImage = useCallback(async () => { const insertImage = useCallback(async () => {
if (!tab) return if (!tab) return
@@ -1180,12 +1360,13 @@ export default function App() {
try { try {
const rel = await copyToAssets(picked, tab.path) const rel = await copyToAssets(picked, tab.path)
withEditor((e) => editorCommands.image(e, rel, baseName(picked))) withEditor((e) => editorCommands.image(e, rel, baseName(picked)))
autoUploadSwap(tab.id, tab.path, rel)
} catch (e) { setError(String(e)) } } catch (e) { setError(String(e)) }
} else { } else {
const picked = await pickAndReadWebImage() const picked = await pickAndReadWebImage()
if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name)) if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name))
} }
}, [tab, tauriMode, withEditor]) }, [tab, tauriMode, withEditor, autoUploadSwap])
const insertAttachment = useCallback(async () => { const insertAttachment = useCallback(async () => {
if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return } if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return }
@@ -1407,6 +1588,16 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[tab?.id, deferredContent], [tab?.id, deferredContent],
) )
// 状态栏「未上传图片」计数:扫当前文档序列化文本里的本地图片引用(去重)
const pendingImages = useMemo(() => {
if (!tab?.content || sourceMode) return 0
const found = new Set<string>()
const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
let m: RegExpExecArray | null
while ((m = re.exec(tab.content))) if (isUploadableImageSrc(m[1])) found.add(m[1])
return found.size
}, [tab?.id, tab?.content, sourceMode])
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
if (!tab) return { words: 0, chars: 0, lines: 0 } if (!tab) return { words: 0, chars: 0, lines: 0 }
return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length } return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length }
@@ -1430,6 +1621,11 @@ export default function App() {
{ label: '导入 HTML / Word…', onClick: () => void importFileFlow() }, { label: '导入 HTML / Word…', onClick: () => void importFileFlow() },
{ label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) }, { label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) },
{ label: '打印 / 导出 PDF…', shortcut: 'Ctrl+P', disabled: !tab, onClick: printDoc }, { label: '打印 / 导出 PDF…', shortcut: 'Ctrl+P', disabled: !tab, onClick: printDoc },
{ divider: true },
{ label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() },
{ label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) },
{ divider: true },
{ label: '设为 .md 默认打开方式…', disabled: !tauriMode, onClick: () => { void (async () => { try { setAssocMsg(await registerFileAssociation()) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } })() } },
], ],
}, },
{ {
@@ -1734,6 +1930,8 @@ export default function App() {
sourceMode={sourceMode} sourceMode={sourceMode}
tauriMode={tauriMode} tauriMode={tauriMode}
zoom={zoom} zoom={zoom}
pendingImages={pendingImages}
onUploadImages={() => void runBatchImageUpload()}
/> />
{gitSettingsOpen && workspaceRoot && ( {gitSettingsOpen && workspaceRoot && (
<GitSyncModal <GitSyncModal
@@ -1747,6 +1945,33 @@ export default function App() {
onClose={() => setGitSettingsOpen(false)} onClose={() => setGitSettingsOpen(false)}
/> />
)} )}
{s3SettingsOpen && (
<S3SettingsModal
initial={ui.s3}
onSave={(next) => { setUi((u) => ({ ...u, s3: next })); setS3SettingsOpen(false); flash('S3 设置已保存') }}
onClose={() => setS3SettingsOpen(false)}
/>
)}
{s3Progress && (
<div className="modal-backdrop">
<div className="modal">
<h3>上传图片到 S3</h3>
{!s3Progress.finished ? (
<p className="modal-note">正在上传 {Math.min(s3Progress.done + 1, s3Progress.total)} / {s3Progress.total}:{s3Progress.current}</p>
) : (
<>
<p className="modal-note">完成:成功 {s3Progress.total - s3Progress.errors.length} 张,失败 {s3Progress.errors.length} 张。</p>
{s3Progress.errors.map((err, i) => (
<p key={i} className="modal-note" style={{ color: 'var(--danger, #c0392b)' }}>{err}</p>
))}
</>
)}
<div className="modal-actions">
<button type="button" disabled={!s3Progress.finished} onClick={() => setS3Progress(null)}>关闭</button>
</div>
</div>
</div>
)}
{appearanceOpen && ( {appearanceOpen && (
<AppearanceModal <AppearanceModal
docTheme={ui.docTheme} docTheme={ui.docTheme}
@@ -1785,6 +2010,7 @@ export default function App() {
x={editorMenu.x} x={editorMenu.x}
y={editorMenu.y} y={editorMenu.y}
hasSelection={editorMenu.hasSelection} hasSelection={editorMenu.hasSelection}
imageSrc={editorMenu.imageSrc}
sourceMode={sourceMode} sourceMode={sourceMode}
onAction={runEditorMenuAction} onAction={runEditorMenuAction}
onClose={() => setEditorMenu(null)} onClose={() => setEditorMenu(null)}
@@ -1887,6 +2113,38 @@ export default function App() {
onSubmit={(href) => { setModal(null); if (href) withEditor((e) => editorCommands.link(e, href)) }} onSubmit={(href) => { setModal(null); if (href) withEditor((e) => editorCommands.link(e, href)) }}
/> />
)} )}
{imageLinkTarget && (
<ImageLinkModal
src={imageLinkTarget.src}
linkHref={imageLinkTarget.href}
onCancel={() => setImageLinkTarget(null)}
onSubmit={({ src, linkHref }) => {
const t = imageLinkTarget
setImageLinkTarget(null)
const patch: { src?: string; linkHref?: string | null } = {}
if (src !== t.src) patch.src = src
if (linkHref !== t.href) patch.linkHref = linkHref || null
if (Object.keys(patch).length === 0) return
const ok = paneApisRef.current.get(t.tabId)?.updateImageForElement(t.el, patch)
if (ok) flash('图片链接已更新')
else setError('图片已不在文档中,链接未更新。')
}}
/>
)}
{assocMsg && <InfoModal title="文件关联" message={assocMsg} onClose={() => setAssocMsg(null)} />}
{imageSizeTarget && (
<ImageSizeModal
current={imageSizeTarget.width}
onCancel={() => setImageSizeTarget(null)}
onSubmit={(width) => {
const t = imageSizeTarget
setImageSizeTarget(null)
const ok = paneApisRef.current.get(t.tabId)?.updateImageForElement(t.el, { width })
if (ok) flash(width ? '图片大小已更新' : '图片已恢复原始大小')
else setError('图片已不在文档中,大小未更新。')
}}
/>
)}
{error && <ErrorModal message={error} onClose={() => setError(null)} />} {error && <ErrorModal message={error} onClose={() => setError(null)} />}
{encodingPrompt && ( {encodingPrompt && (
<EncodingConvertModal <EncodingConvertModal

View File

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

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,
} }
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */ /** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
@@ -231,6 +249,16 @@ 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 已 // 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
@@ -241,7 +269,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
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(

View File

@@ -140,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}
@@ -161,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

@@ -11,6 +11,9 @@ interface Props {
sourceMode: boolean sourceMode: boolean
tauriMode: boolean tauriMode: boolean
zoom: number zoom: number
/** 当前文档未上传的本地图片数(>0 时状态栏出批量上传钮) */
pendingImages?: number
onUploadImages?: () => void
} }
export default function StatusBar(p: Props) { export default function StatusBar(p: Props) {
@@ -25,6 +28,11 @@ export default function StatusBar(p: Props) {
<span>{p.dirty ? '未保存' : '已保存'}</span> <span>{p.dirty ? '未保存' : '已保存'}</span>
<span>{p.sourceMode ? '源代码模式' : '即时渲染'}</span> <span>{p.sourceMode ? '源代码模式' : '即时渲染'}</span>
<span className="status-right"> <span className="status-right">
{Boolean(p.pendingImages && p.onUploadImages) && (
<button type="button" className="status-upload-btn" onClick={p.onUploadImages}>
上传图片到 S3({p.pendingImages} 张未传)
</button>
)}
<span>{Math.round(p.zoom * 100)}%</span> <span>{Math.round(p.zoom * 100)}%</span>
<span>{p.tauriMode ? '桌面模式' : 'Web 降级模式(调试)'}</span> <span>{p.tauriMode ? '桌面模式' : 'Web 降级模式(调试)'}</span>
</span> </span>

View File

@@ -12,8 +12,10 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard'
import { indent } from '@milkdown/kit/plugin/indent' import { indent } from '@milkdown/kit/plugin/indent'
import { math } from '@milkdown/plugin-math' import { math } from '@milkdown/plugin-math'
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload' import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload'
import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, createEmptyPlaceholderPlugin, selectionInTable } from './plugins' import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, createImageEnterPlugin, createEmptyPlaceholderPlugin, selectionInTable } from './plugins'
import { NodeSelection } from '@milkdown/kit/prose/state'
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews' import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
import { extendImageSchema } from './imageSchema'
export interface CursorInfo { line: number; col: number } export interface CursorInfo { line: number; col: number }
@@ -39,6 +41,8 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
ctx.set(defaultValueCtx, initialMarkdown) ctx.set(defaultValueCtx, initialMarkdown)
// 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-` // 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-`
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' }) ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
// 图片尺寸扩展(docs/31):width 属性 + <img> HTML 往返
extendImageSchema(ctx)
const lm = ctx.get(listenerCtx) const lm = ctx.get(listenerCtx)
if (callbacks.onMarkdown) { if (callbacks.onMarkdown) {
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown)) lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
@@ -109,6 +113,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
.use(math) .use(math)
.use(upload) .use(upload)
.use(createPlainTextPastePlugin()) .use(createPlainTextPastePlugin())
.use(createImageEnterPlugin())
if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay)) if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay))
return editor return editor
} }
@@ -165,6 +170,162 @@ export function getMarkdown(editor: Editor): string {
}) })
} }
/** 文档里全部图片的 src(按出现顺序、去重)——S3 批量上传扫描用(docs/30)。 */
export function collectImageSrcs(editor: Editor): string[] {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const seen = new Set<string>()
const out: string[] = []
view.state.doc.descendants((node) => {
if (node.type.name === 'image') {
const src = String(node.attrs.src ?? '')
if (src && !seen.has(src)) { seen.add(src); out.push(src) }
}
return true
})
return out
})
}
/**
* 把文档里所有 src === oldSrc 的图片节点换成 newSrc,返回替换数。
* 一次遍历收集位置、同一事务内 setNodeMarkup:image 是叶子节点,
* 改属性不改变文档尺寸,位置不会失效。
*/
export function replaceImageSrc(editor: Editor, oldSrc: string, newSrc: string): number {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const positions: number[] = []
view.state.doc.descendants((node, pos) => {
if (node.type.name === 'image' && String(node.attrs.src ?? '') === oldSrc) positions.push(pos)
return true
})
if (positions.length === 0) return 0
const tr = view.state.tr
for (const pos of positions) {
const node = view.state.doc.nodeAt(pos)
if (node) tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc })
}
view.dispatch(tr)
return positions.length
})
}
/** 右键命中的 <img> 元素对应的图片节点 src(非图片返回 null)——右键菜单判「本地未上传图」用。 */
export function imageSrcAtElement(editor: Editor, el: HTMLElement): string | null {
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const pos = view.posAtDOM(el, 0)
const node = view.state.doc.nodeAt(pos)
if (!node || node.type.name !== 'image') return null
return String(node.attrs.src ?? '') || null
})
} catch {
return null
}
}
export interface ImageNodeInfo {
src: string
alt: string
/** 尺寸('300' = px、'50%' = 百分比);未设置时为 null */
width: string | null
/** 图片被链接包裹时的链接地址(链接标记的 href);没有时为 null */
linkHref: string | null
}
export interface ImageNodePatch {
src?: string
alt?: string
width?: string | null
/** undefined = 不动链接;null/'' = 去掉链接;字符串 = 设置/更新链接 */
linkHref?: string | null
}
function imagePosAtElement(view: { posAtDOM: (el: Node, off: number) => number; state: { doc: { nodeAt: (pos: number) => unknown } } }, el: HTMLElement): number {
const pos = view.posAtDOM(el, 0)
const node = view.state.doc.nodeAt(pos) as { type: { name: string } } | null
if (!node || node.type.name !== 'image') return -1
return pos
}
/** 读某个 <img> 元素对应图片节点的属性(含尺寸与包裹链接),非图片返回 null。 */
export function imageInfoAtElement(editor: Editor, el: HTMLElement): ImageNodeInfo | null {
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const pos = imagePosAtElement(view, el)
if (pos < 0) return null
const node = view.state.doc.nodeAt(pos)
if (!node) return null
const link = node.marks.find((m) => m.type.name === 'link')
return {
src: String(node.attrs.src ?? ''),
alt: String(node.attrs.alt ?? ''),
width: (node.attrs.width as string | null) ?? null,
linkHref: link ? String(link.attrs.href ?? '') || null : null,
}
})
} catch {
return null
}
}
/**
* 改某个 <img> 元素对应图片节点的属性(地址/替代文本/尺寸/包裹链接),
* 一次事务完成。链接以标记形式挂在图片节点上,序列化为 [![alt](src)](href)。
*/
export function updateImageAtElement(editor: Editor, el: HTMLElement, patch: ImageNodePatch): boolean {
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const pos = imagePosAtElement(view, el)
if (pos < 0) return false
const node = view.state.doc.nodeAt(pos)
if (!node) return false
const tr = view.state.tr
const attrs = { ...node.attrs }
if (patch.src !== undefined) attrs.src = patch.src
if (patch.alt !== undefined) attrs.alt = patch.alt
if (patch.width !== undefined) attrs.width = patch.width
tr.setNodeMarkup(pos, undefined, attrs)
if (patch.linkHref !== undefined) {
const linkType = view.state.schema.marks.link
if (linkType) {
tr.removeMark(pos, pos + 1, linkType)
if (patch.linkHref) tr.addMark(pos, pos + 1, linkType.create({ href: patch.linkHref }))
}
}
// 同 plugins 的拖拽提交:换节点属性后保住节点选区,别被回读塌掉
if (view.state.selection instanceof NodeSelection && view.state.selection.from === pos) {
tr.setSelection(NodeSelection.create(tr.doc, pos))
}
if (tr.docChanged) view.dispatch(tr)
return true
})
} catch {
return false
}
}
/** 选中某个 <img> 元素对应的图片节点(右键图片时先选中它,剪切/复制/删除才作用得准)。 */
export function selectImageAtElement(editor: Editor, el: HTMLElement): boolean {
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const pos = imagePosAtElement(view, el)
if (pos < 0) return false
const sel = view.state.selection
if (sel instanceof NodeSelection && sel.from === pos) return true
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)))
return true
})
} catch {
return false
}
}
export function callUndo(editor: Editor) { export function callUndo(editor: Editor) {
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key)) editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
} }

128
src/editor/imageSchema.ts Normal file
View File

@@ -0,0 +1,128 @@
// 图片尺寸扩展(docs/31):给 Milkdown 图片节点加 width 属性,让「右键 →
// 调整大小」可持久化。Markdown 本身没有图片尺寸语法,业界通行写法是
// HTML:带 width 的图片序列化为 <img src alt width>,无 width 时仍是
// 标准 ![alt](src),不污染普通文档。
// - 解析:remark 阶段把孤立的 <img …>(行内或块级)mdast html 节点换成
// image 节点并把 width 放进 data;图片 schema 的 parseMarkdown runner
// 读出来落到节点 attrs。
// - 序列化:toMarkdown runner 见 width 就发 html 节点,否则原样发 image。
// 往返已实测:<img src="b.png" alt="乙" width="300"> 解析/序列化逐字一致。
import type { Ctx } from '@milkdown/kit/ctx'
import { remarkPluginsCtx } from '@milkdown/kit/core'
import { imageSchema } from '@milkdown/kit/preset/commonmark'
const IMG_TAG = /^<img\s+[^>]*\/?>$/i
/** 解析一个孤立 <img> 标签的属性表;不是孤立 img 或没有 src 时返回 null。 */
export function parseImgTag(html: string): Record<string, string> | null {
const t = html.trim()
if (!IMG_TAG.test(t)) return null
const attrs: Record<string, string> = {}
for (const m of t.matchAll(/([\w-]+)\s*=\s*"([^"]*)"|([\w-]+)\s*=\s*'([^']*)'/g)) {
attrs[(m[1] ?? m[3]).toLowerCase()] = decodeAttr(m[2] ?? m[4])
}
return attrs.src ? attrs : null
}
const ATTR_ENTITIES: Record<string, string> = { '&quot;': '"', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&#39;': "'" }
/** 解码 HTML 属性值里的常见实体(mdast 保留的是源码原文,不解码)。单遍替换防二次解码。 */
function decodeAttr(v: string): string {
return v.replace(/&(?:quot|amp|lt|gt);|&#39;/g, (m) => ATTR_ENTITIES[m] ?? m)
}
interface MdastNode {
type: string
value?: string
children?: MdastNode[]
[key: string]: unknown
}
/** remark 插件:把孤立 <img> 的 mdast html 节点换成带 data.width 的 image 节点。 */
export function remarkImgSizePlugin() {
const toImage = (child: MdastNode): MdastNode | null => {
if (child?.type !== 'html') return null
const attrs = parseImgTag(String(child.value ?? ''))
if (!attrs) return null
return {
type: 'image',
url: attrs.src,
alt: attrs.alt ?? '',
title: attrs.title ?? '',
data: { width: attrs.width ?? null },
} as MdastNode
}
const walk = (node: MdastNode): void => {
if (!node || typeof node !== 'object' || !Array.isArray(node.children)) return
node.children = node.children.map((child) => {
const img = toImage(child)
if (img) {
// 根级是块上下文,image 是行内节点,必须包进段落;段落内原地替换
return node.type === 'root' ? { type: 'paragraph', children: [img] } : img
}
walk(child)
return child
})
}
return (tree: MdastNode) => walk(tree)
}
function escapeAttr(v: string): string {
return v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}
/** 在编辑器 config 阶段调用:注册 remark 插件 + 包裹图片 schema。 */
export function extendImageSchema(ctx: Ctx): void {
// remarkPluginsCtx 的条目类型是 unified 的 Plugin<[..], Root>,与本文件
// 的宽松 mdast 类型对不上;运行时形状 { plugin, options } 已实测无误。
const entry = { plugin: remarkImgSizePlugin, options: undefined } as unknown as
ReturnType<typeof remarkPluginsCtx.create> extends (infer T)[] ? T : never
ctx.update(remarkPluginsCtx, (prev) => [...prev, entry])
ctx.update(imageSchema.key, (factory) => (c) => {
const spec = factory(c)
return {
...spec,
attrs: { ...spec.attrs, width: { default: null } },
parseMarkdown: {
match: (node: { type: string }) => node.type === 'image',
runner: (state, node, type) => {
const mdast = node as unknown as { url: string; alt?: string; title?: string; data?: { width?: string | null } }
state.addNode(type, {
src: mdast.url,
alt: mdast.alt ?? '',
title: mdast.title ?? '',
width: mdast.data?.width ?? null,
})
},
},
toMarkdown: {
match: (node: { type: { name: string } }) => node.type.name === 'image',
runner: (state, node) => {
const width = node.attrs.width as string | null
if (width) {
const title = node.attrs.title ? ` title="${escapeAttr(String(node.attrs.title))}"` : ''
state.addNode(
'html',
undefined,
`<img src="${escapeAttr(String(node.attrs.src))}" alt="${escapeAttr(String(node.attrs.alt))}"${title} width="${escapeAttr(String(width))}">`,
)
} else {
state.addNode('image', undefined, undefined, {
title: node.attrs.title,
url: node.attrs.src,
alt: node.attrs.alt,
})
}
},
},
}
})
}
/** width 属性值 → CSS 宽度:纯数字按 px,带单位(%/px/em…)原样用。 */
export function widthToCss(width: string | null | undefined): string | null {
if (!width) return null
const t = String(width).trim()
if (!t) return null
return /^\d+(\.\d+)?$/.test(t) ? `${t}px` : t
}

View File

@@ -2,7 +2,8 @@
// 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。 // 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。
// 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。 // 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。
// 2) codeBlockTab —— 代码块内 Tab 缩进 / Shift+Tab 反缩进(其余位置 Tab 行为不变)。 // 2) codeBlockTab —— 代码块内 Tab 缩进 / Shift+Tab 反缩进(其余位置 Tab 行为不变)。
import { Plugin, PluginKey } from '@milkdown/kit/prose/state' import { Plugin, PluginKey, NodeSelection, TextSelection } from '@milkdown/kit/prose/state'
import { widthToCss } from './imageSchema'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view' import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import { $prose } from '@milkdown/kit/utils' import { $prose } from '@milkdown/kit/utils'
import { commandsCtx } from '@milkdown/kit/core' import { commandsCtx } from '@milkdown/kit/core'
@@ -416,14 +417,72 @@ export function createPlainTextPastePlugin() {
// resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。 // resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACw='
/**
* 图片选中时按 Enter:在图片所在段落后新建空段落并把光标移过去(docs/31)。
* 默认 Enter 链对图片 NodeSelection 走 splitBlock——新段落插到图片前面且
* 选区仍停在图片上,接着打字会把图片替换掉,用户根本没法在图片后输入。
* handleKeyDown 先于所有 keymap 插件执行,只接管「图片被选中」这一种情形,
* 其余 Enter(列表/标题/代码块/普通文本)一律放行给原有链。
*/
export function createImageEnterPlugin() {
return $prose(() => new Plugin({
// 用插件 view 生命周期挂捕获阶段监听:预置的 Enter keymap 插件排在
// 本插件之前,props.handleKeyDown 轮不到(someProp 按插件序先到先得),
// 捕获监听则保证先于一切编辑器内 keydown 处理。
view: (editorView) => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
if (isImeComposing(event)) return
const { state } = editorView
const sel = state.selection
if (!(sel instanceof NodeSelection) || sel.node.type.name !== 'image') return
// createParagraphNear 对行内节点直接返回 false(它只管块级选区),
// 自己来:在图片所在的文本块之后插入空段落并把光标放进去。
const $pos = state.doc.resolve(sel.from)
let depth = $pos.depth
while (depth > 0 && !$pos.node(depth).isTextblock) depth--
if (depth === 0) return
const paraType = state.schema.nodes.paragraph
if (!paraType) return
const insertAt = $pos.after(depth)
const tr = state.tr.insert(insertAt, paraType.create())
tr.setSelection(TextSelection.create(tr.doc, insertAt + 1))
editorView.dispatch(tr.scrollIntoView())
event.preventDefault()
event.stopPropagation()
}
editorView.dom.addEventListener('keydown', onKeyDown, true)
return {
destroy: () => editorView.dom.removeEventListener('keydown', onKeyDown, true),
}
},
}))
}
export function createImageDisplayPlugin(resolve: (src: string) => Promise<string | null>) { export function createImageDisplayPlugin(resolve: (src: string) => Promise<string | null>) {
return $prose(() => new Plugin({ return $prose(() => new Plugin({
props: { props: {
nodeViews: { nodeViews: {
image: (node) => { image: (node, view, getPos) => {
// 包装结构:span.img-wrap(PM 的节点视图 dom)> img + 拖拽手柄。
// 手柄在图片被选中时显示(.ProseMirror-selectednode 落在包装上),
// 拖动时实时改 img 的 CSS 宽度预览,松手才把宽度提交进节点属性。
const wrap = document.createElement('span')
wrap.className = 'img-wrap'
const img = document.createElement('img') const img = document.createElement('img')
img.alt = (node.attrs.alt as string) ?? '' img.alt = (node.attrs.alt as string) ?? ''
img.draggable = false
if (node.attrs.title) img.title = node.attrs.title as string if (node.attrs.title) img.title = node.attrs.title as string
const handle = document.createElement('span')
handle.className = 'img-resize-handle'
wrap.append(img, handle)
let currentNode = node
const applyWidth = (w: unknown) => {
const css = widthToCss(typeof w === 'string' ? w : null)
if (css) img.style.width = css
else img.style.removeProperty('width')
}
applyWidth(node.attrs.width)
let currentSrc = '' let currentSrc = ''
const apply = (src: string) => { const apply = (src: string) => {
currentSrc = src currentSrc = src
@@ -437,11 +496,53 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise<strin
.catch(() => undefined) .catch(() => undefined)
} }
apply((node.attrs.src as string) ?? '') apply((node.attrs.src as string) ?? '')
// PM 用的是 mouse 事件:手柄上的 mousedown/mouseup/click 必须吞掉,
// 否则松手时 PM 会按点击位置重设选区,手柄随即消失、没法连续拖。
for (const type of ['mousedown', 'mouseup', 'click', 'dblclick'] as const) {
handle.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation() })
}
let drag: { startX: number; startW: number; lastW: number } | null = null
handle.addEventListener('pointerdown', (e) => {
e.preventDefault()
e.stopPropagation()
const w = img.getBoundingClientRect().width
drag = { startX: e.clientX, startW: w, lastW: w }
handle.setPointerCapture(e.pointerId)
})
handle.addEventListener('pointermove', (e) => {
if (!drag) return
const max = wrap.parentElement?.clientWidth || 10000
const w = Math.min(Math.max(20, drag.startW + (e.clientX - drag.startX)), Math.max(20, max))
drag.lastW = w
img.style.width = `${Math.round(w)}px`
})
const endDrag = () => {
if (!drag) return
const w = Math.round(drag.lastW)
drag = null
const pos = typeof getPos === 'function' ? getPos() : undefined
if (pos == null || String(w) === String(currentNode.attrs.width ?? '')) {
applyWidth(currentNode.attrs.width)
return
}
// setNodeMarkup 换掉节点实例后 PM 会按 DOM 选区回读、把节点选区
// 塌成文本光标(选区一丢手柄就消失,没法连续拖)——同一事务里把
// 节点选区明确设回去。
const tr = view.state.tr.setNodeMarkup(pos, undefined, { ...currentNode.attrs, width: String(w) })
tr.setSelection(NodeSelection.create(tr.doc, pos))
view.dispatch(tr)
}
handle.addEventListener('pointerup', endDrag)
handle.addEventListener('pointercancel', endDrag)
return { return {
dom: img, dom: wrap,
update: (next) => { update: (next) => {
if (next.type.name !== 'image') return false if (next.type.name !== 'image') return false
currentNode = next
img.alt = (next.attrs.alt as string) ?? '' img.alt = (next.attrs.alt as string) ?? ''
if (!drag) applyWidth(next.attrs.width)
const src = (next.attrs.src as string) ?? '' const src = (next.attrs.src as string) ?? ''
if (src !== currentSrc) apply(src) if (src !== currentSrc) apply(src)
return true return true

View File

@@ -348,3 +348,40 @@ export async function gitSetRemote(root: string, remoteUrl: string): Promise<Git
export async function gitSyncNow(root: string, message: string): Promise<GitSyncOutcome> { export async function gitSyncNow(root: string, message: string): Promise<GitSyncOutcome> {
return invoke<GitSyncOutcome>('git_sync_now', { root, message }) return invoke<GitSyncOutcome>('git_sync_now', { root, message })
} }
// ——— S3 图片上传(docs/30):配置随调用传入,Rust 侧签名 + PUT,不保存密钥 ———
export interface S3ConfigPayload {
endpoint: string
region: string
bucket: string
accessKey: string
secretKey: string
prefix: string
publicBase: string
}
/** 上传一个本地图片文件,返回应回填文档的公开访问地址。 */
export async function s3UploadImage(config: S3ConfigPayload, filePath: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_image', { config, filePath, fileName })
}
/** 取走待打开文件队列:启动参数/双击第二个文件转来的 .md 路径(docs/32)。 */
export async function takePendingOpenFiles(): Promise<string[]> {
return invoke<string[]>('take_pending_open_files')
}
/** 注册 .md/.markdown 文件关联并打开系统默认应用设置,返回引导文案(docs/32)。 */
export async function registerFileAssociation(): Promise<string> {
return invoke<string>('register_file_association')
}
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_base64', { config, base64, fileName })
}
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
return invoke<string>('s3_test_connection', { config })
}

121
src/services/s3.ts Normal file
View File

@@ -0,0 +1,121 @@
// S3 图片上传的前端侧(docs/30):设置形状、完整性判定、本地图片识别与
// 路径拼接。真正的签名与 PUT 在 Rust(s3.rs),密钥只随调用传入、不落 Rust 盘。
import type { S3ConfigPayload } from './platform'
export interface S3Settings extends S3ConfigPayload {
/** 粘贴/插入图片落盘后自动上传并把引用换成 S3 地址 */
autoUpload: boolean
}
export const DEFAULT_S3_SETTINGS: S3Settings = {
endpoint: '',
region: '',
bucket: '',
accessKey: '',
secretKey: '',
prefix: '',
publicBase: '',
autoUpload: false,
}
/** 四项必填齐了才算配置过(endpoint/前缀/访问域名都是可选)。 */
export function isS3Configured(s: S3Settings): boolean {
return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim())
}
/** 本地图片 = 文档里的相对/本地文件引用;http(s)/blob 已经是网络资源,不需要上传。 */
export function isLocalImageSrc(src: string): boolean {
const t = src.trim()
if (!t) return false
return !/^(https?:|data:|blob:)/i.test(t)
}
/**
* 内嵌 base64 图片(data:image/...;base64,...):未落盘文档粘贴的图片、
* 导入文档自带的图片都是这种形态——同样算「未上传」,上传时直接传字节,
* 不需要本地文件(2026-11-15 用户指正:原先把 data: 一律排除在检测外)。
*/
export function parseDataImage(src: string): { mime: string; base64: string } | null {
const m = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(src.trim())
if (!m) return null
return { mime: m[1].toLowerCase(), base64: m[2].replace(/\s+/g, '') }
}
export function isDataImageSrc(src: string): boolean {
return parseDataImage(src) !== null
}
/** 需要上传的图片 = 本地文件引用 或 内嵌 base64 图片。 */
export function isUploadableImageSrc(src: string): boolean {
return isLocalImageSrc(src) || isDataImageSrc(src)
}
/**
* base64 图片的对象文件名:image-<内容哈希>.<扩展名>。
* 用内容哈希(FNV-1a)命名:同一张图重复上传同名覆盖、幂等;不同图不撞名。
*/
export function dataImageFileName(src: string): string | null {
const parsed = parseDataImage(src)
if (!parsed) return null
let ext = parsed.mime.split('/')[1] ?? 'png'
ext = ext.split('+')[0]
if (ext === 'jpeg') ext = 'jpg'
if (!/^[a-z0-9]+$/.test(ext)) ext = 'png'
let h = 0x811c9dc5
const b64 = parsed.base64
for (let i = 0; i < b64.length; i++) {
h ^= b64.charCodeAt(i)
h = Math.imul(h, 0x01000193)
}
return `image-${(h >>> 0).toString(16).padStart(8, '0')}.${ext}`
}
/** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */
export function s3ConfigPayload(s: S3Settings): S3ConfigPayload {
return {
endpoint: s.endpoint,
region: s.region,
bucket: s.bucket,
accessKey: s.accessKey,
secretKey: s.secretKey,
prefix: s.prefix,
publicBase: s.publicBase,
}
}
/**
* 把文档相对路径的图片 src 拼成绝对文件路径(供 Rust 读文件)。
* 文本级归一:统一 / 分隔、处理 . 与 ..(图片一般在 <文档名>.assets/ 下,
* 手写文档也可能用 ../ 引用)。Windows 盘符与正斜杠都兼容。
*/
export function resolveDocRelativePath(docPath: string, relSrc: string): string {
const decoded = safeDecode(relSrc).replace(/\\/g, '/')
if (/^[a-zA-Z]:\//.test(decoded) || decoded.startsWith('/')) return decoded
const dir = docPath.replace(/\\/g, '/').replace(/\/[^/]*$/, '')
const parts: string[] = []
for (const seg of `${dir}/${decoded}`.split('/')) {
if (seg === '' || seg === '.') {
if (seg === '' && parts.length === 0) parts.push('')
continue
}
if (seg === '..') { parts.pop(); continue }
parts.push(seg)
}
return parts.join('/') || '/'
}
function safeDecode(s: string): string {
try { return decodeURIComponent(s) } catch { return s }
}
/** 进度/报错里展示的图片名:base64 图用其对象名(哈希名),文件图用原文件名。 */
export function imageDisplayName(src: string): string {
return dataImageFileName(src) ?? imageFileName(src)
}
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
export function imageFileName(src: string): string {
const clean = safeDecode(src).replace(/\\/g, '/')
const name = clean.split('/').pop() ?? ''
return name || 'image.png'
}

View File

@@ -97,6 +97,8 @@ button { font: inherit; color: inherit; }
.sidebar-tabs .sidebar-collapse:hover { color: var(--text); background: var(--hover); } .sidebar-tabs .sidebar-collapse:hover { color: var(--text); background: var(--hover); }
.sidebar-expand { position: absolute; left: 10px; top: 10px; z-index: 35; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 7px; color: var(--text-dim); cursor: pointer; } .sidebar-expand { position: absolute; left: 10px; top: 10px; z-index: 35; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 7px; color: var(--text-dim); cursor: pointer; }
.sidebar-expand:hover { color: var(--text); border-color: var(--accent); } .sidebar-expand:hover { color: var(--text); border-color: var(--accent); }
.status-upload-btn { background: transparent; border: 1px solid var(--border); color: var(--text-dim); border-radius: 999px; padding: 1px 9px; font-size: 11.5px; font-family: inherit; cursor: pointer; }
.status-upload-btn:hover { color: var(--accent); border-color: var(--accent); }
.source-toggle { position: absolute; right: 14px; bottom: 14px; z-index: 35; background: var(--bg-panel); color: var(--text-dim); border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.10); } .source-toggle { position: absolute; right: 14px; bottom: 14px; z-index: 35; background: var(--bg-panel); color: var(--text-dim); border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.10); }
.source-toggle:hover { color: var(--text); border-color: var(--accent); } .source-toggle:hover { color: var(--text); border-color: var(--accent); }
.tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } .tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
@@ -159,6 +161,13 @@ button { font: inherit; color: inherit; }
.modal-actions button { border: 1px solid var(--border); background: var(--bg); border-radius: 7px; padding: 6px 14px; cursor: pointer; } .modal-actions button { border: 1px solid var(--border); background: var(--bg); border-radius: 7px; padding: 6px 14px; cursor: pointer; }
.modal-actions button.primary { background: var(--accent); color: #fff; border-color: transparent; } .modal-actions button.primary { background: var(--accent); color: #fff; border-color: transparent; }
.modal-input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); color: var(--text); } .modal-input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); color: var(--text); }
.modal-field-label { display: block; font-size: 12.5px; color: var(--text-dim); margin: 10px 0 4px; }
.modal-field-label:first-of-type { margin-top: 0; }
.modal-field-row { display: flex; gap: 8px; align-items: stretch; }
.modal-field-row .modal-input { flex: 1; }
.seg-control { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
.seg-control button { padding: 0 12px; background: var(--bg); color: var(--text-dim); border: none; cursor: pointer; font-size: 13px; }
.seg-control button.active { background: var(--accent); color: #fff; }
.modal-note { color: var(--text-dim); font-size: 12.5px; } .modal-note { color: var(--text-dim); font-size: 12.5px; }
.shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; } .shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; }
.shortcuts-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); } .shortcuts-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); }
@@ -177,6 +186,11 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.milkdown .ProseMirror table { border-collapse: collapse; } .milkdown .ProseMirror table { border-collapse: collapse; }
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; } .milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
.milkdown .ProseMirror img { max-width: 100%; } .milkdown .ProseMirror img { max-width: 100%; }
.milkdown .ProseMirror img.ProseMirror-selectednode { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.milkdown .ProseMirror .img-wrap { position: relative; display: inline-block; max-width: 100%; line-height: 0; }
.milkdown .ProseMirror .img-wrap.ProseMirror-selectednode { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.milkdown .ProseMirror .img-wrap .img-resize-handle { position: absolute; right: -6px; bottom: -6px; width: 12px; height: 12px; border-radius: 3px; background: var(--accent); border: 2px solid var(--bg-editor, #fff); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); cursor: nwse-resize; opacity: 0; pointer-events: none; z-index: 3; }
.milkdown .ProseMirror .img-wrap.ProseMirror-selectednode .img-resize-handle { opacity: 1; pointer-events: auto; }
.milkdown .ProseMirror a { color: var(--accent); } .milkdown .ProseMirror a { color: var(--accent); }
.milkdown .ProseMirror hr { border: 0; border-top: 1px solid var(--border); } .milkdown .ProseMirror hr { border: 0; border-top: 1px solid var(--border); }
.milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; } .milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; }

View File

@@ -13,7 +13,7 @@ async function renderMenu(props: Record<string, unknown> = {}) {
await act(async () => { await act(async () => {
root.render( root.render(
<EditorContextMenu <EditorContextMenu
x={10} y={10} hasSelection={false} sourceMode={false} x={10} y={10} hasSelection={false} imageSrc={null} sourceMode={false}
onAction={(a) => actions.push(a)} onAction={(a) => actions.push(a)}
onClose={() => { closed += 1 }} onClose={() => { closed += 1 }}
{...props} {...props}
@@ -38,6 +38,34 @@ describe('编辑区右键菜单', () => {
container.remove() container.remove()
}) })
it('命中本地图片时多一项「上传到 S3」;网络图片不出现', async () => {
const local = await renderMenu({ imageSrc: 'doc.assets/a.png' })
const up = btn(local.container, '上传到 S3')
expect(up).toBeTruthy()
await act(async () => { up?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(local.actions).toEqual(['uploadS3'])
await act(async () => { local.root.unmount() })
local.container.remove()
const remote = await renderMenu({ imageSrc: 'https://cdn.example.com/a.png' })
expect(btn(remote.container, '上传到 S3')).toBeUndefined()
await act(async () => { remote.root.unmount() })
remote.container.remove()
})
it('命中图片时是精简专用菜单:无格式项/全选/源码模式,有超链接与调整大小', async () => {
const { container, root, actions } = await renderMenu({ imageSrc: 'https://cdn.example.com/a.png', hasSelection: true })
for (const t of ['加粗', '斜体', '删除线', '行内代码', '全选', '源码模式', '上传到 S3']) {
expect(btn(container, t)).toBeUndefined()
}
for (const t of ['超链接…', '调整图片大小…', '剪切', '复制', '粘贴']) {
expect(btn(container, t)).toBeTruthy()
}
await act(async () => { btn(container, '调整图片大小…')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(actions).toEqual(['imageSize'])
await act(async () => { root.unmount() })
container.remove()
})
it('源码模式只出编辑命令与「即时渲染」', async () => { it('源码模式只出编辑命令与「即时渲染」', async () => {
const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true }) const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true })
expect(btn(container, '加粗')).toBeUndefined() expect(btn(container, '加粗')).toBeUndefined()

View File

@@ -0,0 +1,53 @@
// 编辑器图片节点操作(docs/30):收集 src、按旧 src 回换、按 DOM 元素反查 src。
import { describe, it, expect } from 'vitest'
import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc, imageSrcAtElement } from '../src/editor/editor'
const DOC = [
'# 图',
'',
'![本地](doc.assets/a.png)',
'',
'![网络](https://cdn.example.com/b.png)',
'',
'![重复](doc.assets/a.png)',
'',
].join('\n')
async function makeEditor() {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, DOC)
await editor.create()
return { root, editor }
}
describe('editor image ops', () => {
it('收集全部图片 src 并去重', async () => {
const { root, editor } = await makeEditor()
expect(collectImageSrcs(editor)).toEqual(['doc.assets/a.png', 'https://cdn.example.com/b.png'])
await editor.destroy()
root.remove()
})
it('按旧 src 回换全部同名图片节点(含重复引用)', async () => {
const { root, editor } = await makeEditor()
const n = replaceImageSrc(editor, 'doc.assets/a.png', 'https://s3.example.com/a.png')
expect(n).toBe(2)
const md = getMarkdown(editor)
expect(md).toContain('https://s3.example.com/a.png')
expect(md).not.toContain('doc.assets/a.png')
expect(collectImageSrcs(editor)).toEqual(['https://s3.example.com/a.png', 'https://cdn.example.com/b.png'])
await editor.destroy()
root.remove()
})
it('按 <img> 元素反查节点 src;非图片元素返回 null', async () => {
const { root, editor } = await makeEditor()
const img = root.querySelector('img')
expect(img).not.toBeNull()
expect(imageSrcAtElement(editor, img as HTMLElement)).toBe('doc.assets/a.png')
expect(imageSrcAtElement(editor, root)).toBeNull()
await editor.destroy()
root.remove()
})
})

44
tests/image-enter.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { editorViewCtx } from '@milkdown/kit/core'
import { NodeSelection } from '@milkdown/kit/prose/state'
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
describe('图片选中时按 Enter(docs/31)', () => {
it('在图片段落后新建空段落并把光标移入,图片保留', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, '前文\n\n![图](a.png)\n\n后文\n')
await editor.create()
const after = editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
let pos = -1
view.state.doc.descendants((n, p) => { if (n.type.name === 'image') pos = p; return true })
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)))
view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
const blocks: string[] = []
view.state.doc.forEach((n) => blocks.push(n.type.name + ':' + n.textContent + (n.content.size && n.firstChild?.type.name === 'image' ? '[img]' : '')))
return { blocks, selType: view.state.selection.constructor.name, selFrom: view.state.selection.from }
})
expect(after.blocks).toEqual(['paragraph:前文', 'paragraph:[img]', 'paragraph:', 'paragraph:后文'])
expect(after.selType).toContain('TextSelection')
expect(getMarkdown(editor)).toContain('![图](a.png)')
await editor.destroy(); root.remove()
})
it('普通文本处按 Enter 不被接管(仍是原有分段行为)', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, '你好世界\n')
await editor.create()
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
// 光标放文本中间(非图片选区),捕获监听应放行——这里只断言监听不误伤:
// jsdom 里默认 Enter 链未必完整执行,至少文档不能被插入多余段落到图片逻辑位置
view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
return null
})
// 不抛错、编辑器仍可用即可(默认链行为由无头浏览器实测覆盖)
expect(getMarkdown(editor)).toContain('你好世界')
await editor.destroy(); root.remove()
})
})

View File

@@ -0,0 +1,45 @@
// 图片链接弹窗(docs/31 更正):打开就带出图片地址,包裹链接在第二字段。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { ImageLinkModal } from '../src/components/Modals'
function setInput(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('ImageLinkModal', () => {
it('预填图片地址与已有包裹链接;改地址后提交回传两者', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onSubmit = vi.fn()
await act(async () => {
root.render(<ImageLinkModal src="doc.assets/a.png" linkHref="https://x.dev" onSubmit={onSubmit} onCancel={() => undefined} />)
})
const inputs = container.querySelectorAll<HTMLInputElement>('.modal-input')
expect(inputs.length).toBe(2)
expect(inputs[0].value).toBe('doc.assets/a.png')
expect(inputs[1].value).toBe('https://x.dev')
await act(async () => { setInput(inputs[0], 'https://cdn.example.com/a.png') })
const ok = [...container.querySelectorAll('button')].find((b) => b.textContent === '确定')!
await act(async () => { ok.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onSubmit).toHaveBeenCalledWith({ src: 'https://cdn.example.com/a.png', linkHref: 'https://x.dev' })
root.unmount(); container.remove()
})
it('无包裹链接时第二字段为空', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(<ImageLinkModal src="https://img.example.com/b.png" linkHref="" onSubmit={() => undefined} onCancel={() => undefined} />)
})
const inputs = container.querySelectorAll<HTMLInputElement>('.modal-input')
expect(inputs[0].value).toBe('https://img.example.com/b.png')
expect(inputs[1].value).toBe('')
root.unmount(); container.remove()
})
})

130
tests/image-size.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest'
import { editorViewCtx } from '@milkdown/kit/core'
import { createMarkdownEditor, getMarkdown, imageInfoAtElement, updateImageAtElement, selectImageAtElement } from '../src/editor/editor'
import { NodeSelection } from '@milkdown/kit/prose/state'
import { parseImgTag, widthToCss } from '../src/editor/imageSchema'
async function makeEditor(md: string) {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, md)
await editor.create()
return { root, editor }
}
function imageAttrs(editor: Awaited<ReturnType<typeof makeEditor>>['editor']) {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const list: Array<Record<string, unknown>> = []
view.state.doc.descendants((n) => { if (n.type.name === 'image') list.push({ ...n.attrs }); return true })
return list
})
}
describe('图片尺寸往返(docs/31)', () => {
it('普通 markdown 图片不带尺寸、原文不变', async () => {
const { root, editor } = await makeEditor('前文 ![图](a.png) 后文\n')
expect(imageAttrs(editor)).toEqual([{ src: 'a.png', alt: '图', title: '', width: null }])
expect(getMarkdown(editor)).toBe('前文 ![图](a.png) 后文\n')
await editor.destroy(); root.remove()
})
it('块级 <img width> 解析为带尺寸的图片节点,序列化逐字还原', async () => {
const md = '前文\n\n<img src="b.png" alt="乙" width="300">\n\n后文\n'
const { root, editor } = await makeEditor(md)
expect(imageAttrs(editor)).toEqual([{ src: 'b.png', alt: '乙', title: '', width: '300' }])
expect(getMarkdown(editor)).toBe(md)
await editor.destroy(); root.remove()
})
it('行内 <img width="50%"> 同样识别,序列化补 alt=""', async () => {
const { root, editor } = await makeEditor('前 <img src="a.png" width="50%"> 后\n')
expect(imageAttrs(editor)[0].width).toBe('50%')
expect(getMarkdown(editor)).toBe('前 <img src="a.png" alt="" width="50%"> 后\n')
await editor.destroy(); root.remove()
})
it('非 img 的 HTML 不受影响(保持 html 节点原文)', async () => {
const md = '前 <span class="x">标</span> 后\n'
const { root, editor } = await makeEditor(md)
expect(imageAttrs(editor)).toEqual([])
expect(getMarkdown(editor)).toBe(md)
await editor.destroy(); root.remove()
})
it('给图片设尺寸 → 序列化为 HTML;清掉尺寸 → 回到 markdown 语法', async () => {
const { root, editor } = await makeEditor('![图](a.png)\n')
const img = root.querySelector('img')!
expect(updateImageAtElement(editor, img, { width: '200' })).toBe(true)
expect(getMarkdown(editor)).toBe('<img src="a.png" alt="图" width="200">\n')
expect(updateImageAtElement(editor, img, { width: null })).toBe(true)
expect(getMarkdown(editor)).toBe('![图](a.png)\n')
await editor.destroy(); root.remove()
})
it('alt 里的引号在 HTML 序列化时转义、解析时还原', async () => {
const { root, editor } = await makeEditor('<img src="a.png" alt="a&quot;b" width="10">\n')
expect(imageAttrs(editor)[0].alt).toBe('a"b')
expect(getMarkdown(editor)).toBe('<img src="a.png" alt="a&quot;b" width="10">\n')
await editor.destroy(); root.remove()
})
})
describe('右键选中图片(docs/31)', () => {
it('selectImageAtElement 把图片设为节点选区', async () => {
const { root, editor } = await makeEditor('前文 ![图](a.png) 后文\n')
const img = root.querySelector('img')!
expect(selectImageAtElement(editor, img)).toBe(true)
const sel = editor.action((ctx) => ctx.get(editorViewCtx).state.selection)
expect(sel instanceof NodeSelection).toBe(true)
await editor.destroy(); root.remove()
})
})
describe('parseImgTag / widthToCss', () => {
it('只认孤立 img 且必须有 src', () => {
expect(parseImgTag('<img src="a.png" width="300">')).toEqual({ src: 'a.png', width: '300' })
expect(parseImgTag('<img alt="x">')).toBeNull()
expect(parseImgTag('<img src="a.png"> 后')).toBeNull()
expect(parseImgTag('<div><img src="a.png"></div>')).toBeNull()
})
it('纯数字补 px,带单位原样', () => {
expect(widthToCss('300')).toBe('300px')
expect(widthToCss('50%')).toBe('50%')
expect(widthToCss(null)).toBeNull()
expect(widthToCss('')).toBeNull()
})
})
describe('图片链接标记(docs/31)', () => {
it('info/update:设置包裹链接 → [![alt](src)](href),清空则还原', async () => {
const { root, editor } = await makeEditor('![图](a.png)\n')
const img = root.querySelector('img')!
expect(imageInfoAtElement(editor, img)).toEqual({ src: 'a.png', alt: '图', width: null, linkHref: null })
expect(updateImageAtElement(editor, img, { linkHref: 'https://example.com' })).toBe(true)
// 无 NodeView 的测试环境里标记变化会重渲染 img 元素,重新取一次
const img2 = root.querySelector('img')!
expect(imageInfoAtElement(editor, img2)?.linkHref).toBe('https://example.com')
expect(getMarkdown(editor)).toBe('[![图](a.png)](https://example.com)\n')
expect(updateImageAtElement(editor, img2, { linkHref: null })).toBe(true)
expect(getMarkdown(editor)).toBe('![图](a.png)\n')
await editor.destroy(); root.remove()
})
it('改图片地址(src):info 带出原地址,patch 后序列化与回读都是新地址', async () => {
const { root, editor } = await makeEditor('![图](doc.assets/a.png)\n')
const img = root.querySelector('img')!
expect(imageInfoAtElement(editor, img)?.src).toBe('doc.assets/a.png')
expect(updateImageAtElement(editor, img, { src: 'https://cdn.example.com/a.png' })).toBe(true)
expect(getMarkdown(editor)).toBe('![图](https://cdn.example.com/a.png)\n')
expect(imageInfoAtElement(editor, root.querySelector('img')!)?.src).toBe('https://cdn.example.com/a.png')
await editor.destroy(); root.remove()
})
it('已有包裹链接的文档解析后能读出 href', async () => {
const { root, editor } = await makeEditor('[![图](a.png)](https://x.dev)\n')
const img = root.querySelector('img')!
expect(imageInfoAtElement(editor, img)?.linkHref).toBe('https://x.dev')
await editor.destroy(); root.remove()
})
})

24
tests/info-modal.test.tsx Normal file
View File

@@ -0,0 +1,24 @@
// 通用信息弹窗(docs/32 文件关联引导用):标题/正文渲染,点「知道了」关闭。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { InfoModal } from '../src/components/Modals'
describe('InfoModal', () => {
it('渲染标题与正文,点知道了回调关闭', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onClose = vi.fn()
await act(async () => {
root.render(<InfoModal title="文件关联" message="已注册为候选打开方式。" onClose={onClose} />)
})
expect(container.textContent).toContain('文件关联')
expect(container.textContent).toContain('已注册为候选打开方式。')
const btn = [...container.querySelectorAll('button')].find((b) => b.textContent === '知道了')!
await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onClose).toHaveBeenCalledTimes(1)
await act(async () => { root.unmount() })
container.remove()
})
})

49
tests/s3-modal.test.tsx Normal file
View File

@@ -0,0 +1,49 @@
// S3 设置弹窗(docs/30):字段回填、编辑后保存的载荷、自动上传开关。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { S3SettingsModal } from '../src/components/S3SettingsModal'
import { DEFAULT_S3_SETTINGS } from '../src/services/s3'
function setInput(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('S3SettingsModal', () => {
it('填写桶/区域/密钥与自动上传开关后,保存回传完整设置', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onSave = vi.fn()
await act(async () => {
root.render(<S3SettingsModal initial={DEFAULT_S3_SETTINGS} onSave={onSave} onClose={() => undefined} />)
})
const inputs = [...container.querySelectorAll('input')]
const byPlaceholder = (ph: string) => inputs.find((i) => i.placeholder === ph) as HTMLInputElement
await act(async () => {
setInput(byPlaceholder('us-east-1'), 'ap-northeast-1')
setInput(byPlaceholder('my-images'), 'my-images')
setInput(byPlaceholder('notes/'), 'blog/')
})
const textInputs = inputs.filter((i) => i.type === 'text' || i.type === 'password')
// Access Key / Secret Key 是无 placeholder 的两个输入(第 4、5 个字段)
await act(async () => {
setInput(textInputs[3], 'AKID')
setInput(textInputs[4], 'SECRET')
})
const checkbox = inputs.find((i) => i.type === 'checkbox') as HTMLInputElement
await act(async () => { checkbox.click() })
const saveBtn = [...container.querySelectorAll('button')].find((b) => b.textContent === '保存') as HTMLButtonElement
await act(async () => { saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onSave).toHaveBeenCalledTimes(1)
const saved = onSave.mock.calls[0][0]
expect(saved).toMatchObject({
region: 'ap-northeast-1', bucket: 'my-images', accessKey: 'AKID', secretKey: 'SECRET',
prefix: 'blog/', autoUpload: true,
})
await act(async () => { root.unmount() })
container.remove()
})
})

64
tests/s3.test.ts Normal file
View File

@@ -0,0 +1,64 @@
// S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。
import { describe, it, expect } from 'vitest'
import { isLocalImageSrc, isS3Configured, resolveDocRelativePath, imageFileName, isUploadableImageSrc, parseDataImage, dataImageFileName, DEFAULT_S3_SETTINGS } from '../src/services/s3'
describe('isLocalImageSrc', () => {
it('相对路径算本地;http/data/blob 不算', () => {
expect(isLocalImageSrc('doc.assets/a.png')).toBe(true)
expect(isLocalImageSrc('../shared/a.png')).toBe(true)
expect(isLocalImageSrc('https://cdn.example.com/a.png')).toBe(false)
expect(isLocalImageSrc('http://x/a.png')).toBe(false)
expect(isLocalImageSrc('data:image/png;base64,xx')).toBe(false)
expect(isLocalImageSrc('blob:abc')).toBe(false)
expect(isLocalImageSrc('')).toBe(false)
})
})
describe('base64 内嵌图片', () => {
const png = 'data:image/png;base64,UE5HREFUQS0xMjM='
it('data:image 算未上传;普通 data: 与网络图不算', () => {
expect(isUploadableImageSrc(png)).toBe(true)
expect(isUploadableImageSrc('data:text/plain;base64,xx')).toBe(false)
expect(isUploadableImageSrc('https://x/a.png')).toBe(false)
})
it('解析出 mime 与纯 base64(剥空白)', () => {
expect(parseDataImage(png)).toEqual({ mime: 'image/png', base64: 'UE5HREFUQS0xMjM=' })
expect(parseDataImage('data:image/jpeg;base64,AA\nBB')).toEqual({ mime: 'image/jpeg', base64: 'AABB' })
expect(parseDataImage('doc.assets/a.png')).toBeNull()
})
it('对象名按内容哈希:同图同名、异图异名,jpeg 归一为 jpg', () => {
const a = dataImageFileName(png)
expect(a).toMatch(/^image-[0-9a-f]{8}\.png$/)
expect(dataImageFileName(png)).toBe(a)
expect(dataImageFileName('data:image/png;base64,QUJD')).not.toBe(a)
expect(dataImageFileName('data:image/jpeg;base64,QUJD')?.endsWith('.jpg')).toBe(true)
expect(dataImageFileName('data:image/svg+xml;base64,QUJD')?.endsWith('.svg')).toBe(true)
expect(dataImageFileName('doc.assets/a.png')).toBeNull()
})
})
describe('isS3Configured', () => {
it('四项必填齐才算配置过', () => {
expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false)
expect(isS3Configured({ ...DEFAULT_S3_SETTINGS, bucket: 'b', region: 'r', accessKey: 'a', secretKey: 's' })).toBe(true)
expect(isS3Configured({ ...DEFAULT_S3_SETTINGS, bucket: 'b', region: 'r', accessKey: 'a' })).toBe(false)
})
})
describe('resolveDocRelativePath', () => {
it('文档同目录 .assets 拼接(含 Windows 路径与 ..)', () => {
expect(resolveDocRelativePath('D:/notes/todo.md', 'todo.assets/a.png')).toBe('D:/notes/todo.assets/a.png')
expect(resolveDocRelativePath('D:\\notes\\todo.md', 'todo.assets\\a.png')).toBe('D:/notes/todo.assets/a.png')
expect(resolveDocRelativePath('/home/u/notes/todo.md', '../shared/a.png')).toBe('/home/u/shared/a.png')
expect(resolveDocRelativePath('/home/u/notes/todo.md', 'a%20b.png')).toBe('/home/u/notes/a b.png')
expect(resolveDocRelativePath('/home/u/notes/todo.md', 'C:/abs/a.png')).toBe('C:/abs/a.png')
})
})
describe('imageFileName', () => {
it('取文件名(含解码与反斜杠)', () => {
expect(imageFileName('doc.assets/图 1.png')).toBe('图 1.png')
expect(imageFileName('a%20b.png')).toBe('a b.png')
expect(imageFileName('x\\y\\z.jpg')).toBe('z.jpg')
})
})