Compare commits
12 Commits
fb2315a2f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c09d94501 | ||
|
|
5f4a80fa01 | ||
|
|
fb51276bc5 | ||
|
|
b3c22fd597 | ||
|
|
967aada3ca | ||
| 03c6c15439 | |||
| 8194345667 | |||
| 499cec2273 | |||
|
|
cd64110bf8 | ||
|
|
eb27e4ca2d | ||
|
|
4fca4b7143 | ||
|
|
c602573d8e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ target/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
*.tsbuildinfo
|
||||
.idea/
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
@@ -132,6 +132,13 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据
|
||||
- D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16
|
||||
文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但
|
||||
落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。
|
||||
→ **已于当晚按用户拍板实现**:saveTab 在落盘前拦截,首次保存
|
||||
弹 EncodingConvertModal 说明将转 UTF-8(按路径记已确认、本次
|
||||
运行只提示一次);自动保存不弹窗也不静默转码,跳过该文档等
|
||||
手动保存时提示;确认后以冻结快照完成落盘,标签 encoding 如实
|
||||
更新为 UTF-8。弹窗渲染置于所有弹窗之后(避免被关窗确认框
|
||||
盖住)。vitest 123+2(含弹窗契约测试)。真机验证点:拿一个
|
||||
GBK 文档改字后 Ctrl+S,应先弹提示、确认后才落盘。
|
||||
- D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与
|
||||
顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。
|
||||
- D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms
|
||||
@@ -183,3 +190,11 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据
|
||||
- H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用
|
||||
「打开 >60K 文档后立刻连开多个标签挤掉其窗格,再回看该文档
|
||||
是否完整」一验。
|
||||
- **勘误(2026-11-14 晚,Windows 构建回报)**:本轮 M1 与 docs/27
|
||||
的 Git 命令共 6 处用了 `tokio::task::spawn_blocking`,但工程并
|
||||
没有 tokio 直接依赖(Cargo.toml 只有 tauri 等 7 个),Windows
|
||||
构建报 E0433。已全部改为工程既有写法的
|
||||
`tauri::async_runtime::spawn_blocking`(read/write 命令一直在
|
||||
用),并全量核对源码中所有外部 crate 前缀与 Cargo.toml 依赖
|
||||
一致、无其他幻影 crate。教训:写 Rust 时新引入的 crate 路径
|
||||
必须先对 Cargo.toml 存量,沙箱无法完整编译时尤其如此。
|
||||
|
||||
78
docs/30-S3图片上传.md
Normal file
78
docs/30-S3图片上传.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# docs/30 — S3 图片上传
|
||||
|
||||
需求(2026-11-15 用户提出):①设置里能配 S3 并有「自动上传图片」开关;
|
||||
②未上传的图片右键要有「上传到 S3」;③整个文档要有批量检测未上传图片
|
||||
并上传的按钮。
|
||||
|
||||
## 设计
|
||||
|
||||
- **协议层在 Rust**(`src-tauri/src/s3.rs`):AWS SigV4 签名 + PUT Object,
|
||||
reqwest(blocking + rustls)。配置随每次调用从前端传入,Rust 不落盘
|
||||
保存密钥。命令 2 个(总数 30 与 lib.rs 注册对账):`s3_upload_image`、
|
||||
`s3_test_connection`(对桶发签名 HEAD,按 200/403/404 给可读结论)。
|
||||
- **寻址风格**:Endpoint 留空 = AWS S3 虚拟主机风格
|
||||
`https://{bucket}.s3.{region}.amazonaws.com/{key}`;填了自定义
|
||||
Endpoint(MinIO / R2 / OSS 等 S3 兼容服务)走路径风格
|
||||
`{endpoint}/{bucket}/{key}`。对象键 = 前缀 + 原文件名(同名覆盖,
|
||||
重复上传幂等)。配了「自定义访问域名」时,回填文档的地址按
|
||||
「域名/存储桶/前缀/文件名」拼——MinIO 等自建服务里 API 地址与对外
|
||||
访问地址是两个域名、访问域名通常指向服务根,桶名必须出现在路径里
|
||||
(2026-11-15 用户指正,原实现漏了桶名一段)。不填访问域名时回填的
|
||||
就是上传请求本身的地址(路径风格已含桶名、AWS 虚拟主机风格桶名在
|
||||
域名里)。
|
||||
- **配置存储**:进 UiState.s3(本机偏好库 SQLite/localStorage),字段
|
||||
endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload;
|
||||
加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。
|
||||
弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。
|
||||
- **「未上传」判定**:本地文件引用(相对路径)或内嵌 base64 图片
|
||||
(`data:image/...;base64,...`)都算未上传(`isUploadableImageSrc`);
|
||||
http(s)/blob 不算。base64 形态来自未落盘文档的粘贴图与导入文档自带
|
||||
图(2026-11-15 用户指正:初版把 data: 一律排除、检测不到)。base64
|
||||
图没有文件可读,走单独的 `s3_upload_base64` 命令(命令总数 31):
|
||||
前端剥前缀传纯 base64,Rust 解码后与文件图共用同一条 PUT 通路;
|
||||
对象名按内容哈希生成(image-<FNV-1a>.<扩展名>),同图同名幂等。
|
||||
上传成功后把编辑器里所有同 src 的图片节点
|
||||
一次事务换成 S3 地址(`replaceImageSrc`,同图多处引用一并换),文档
|
||||
随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉
|
||||
离线打开时的图,且用户可能还要本地备份)。
|
||||
|
||||
## 三个入口
|
||||
|
||||
1. **自动上传**(开关在 S3 设置里):粘贴/拖入(handleImageFile 落盘后)
|
||||
与「插入图片」(copyToAssets 后)触发后台上传+回换;粘贴建节点是
|
||||
异步的,第一次回换扑空时 900ms 后重试一次;再扑空或上传失败都给
|
||||
提示(flash),不静默、本地引用保留。
|
||||
2. **右键单张**:编辑区右键命中 <img> 时经 `imageSrcForElement`
|
||||
(posAtDOM 反查节点)拿到文档 src;本地图时菜单多一项「上传到 S3」。
|
||||
未配置 S3 时点它先弹设置弹窗并提示。
|
||||
3. **批量**:文件 →「上传文档图片到 S3…」+ 状态栏按钮(当前文档有未
|
||||
上传本地图片时出现,带张数)。扫描走编辑器 `listImages()`(PM 文档
|
||||
遍历、去重),逐张上传回换,进度弹窗实时显示 第 N/总 张与当前文件
|
||||
名,结束汇总成功/失败明细。源码模式下不可用(菜单禁用、状态栏不
|
||||
计数),提示切回即时渲染。
|
||||
|
||||
## 验证
|
||||
|
||||
- **签名算法**:独立小 crate 里对照 AWS 官方文档示例(IAM ListUsers,
|
||||
20150830T123600Z)——算出的 Signature 与官方值
|
||||
`5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7`
|
||||
逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。
|
||||
- **base64 通路**:同一独立 crate 里实测 upload_base64:解码后的字节
|
||||
与原文一致地 PUT 到模拟 S3、Content-Type 按扩展名、非法 base64 在
|
||||
发请求前就报错。
|
||||
- **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地
|
||||
TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、
|
||||
x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、
|
||||
自定义访问域名回填、HEAD 桶测试连接、空配置校验,2 例全过。
|
||||
- **前端**:vitest 133+2(新增 s3.test 路径/判定、editor-images 收集/
|
||||
回换/元素反查、s3-modal 保存载荷、右键菜单图片项);tsc 与构建通过;
|
||||
设置弹窗无头浏览器截图核对(七个字段、开关、三按钮齐全)。
|
||||
- **Cargo.lock 已更新**(reqwest 0.12.28 等,本机 cargo fetch 完成)。
|
||||
|
||||
## 待真机验证(Windows)
|
||||
|
||||
- Rust 完整构建(新增 reqwest/sha2/hmac 依赖)与真实 S3/兼容服务联调:
|
||||
填真实配置 → 测试连接 → 粘贴图片自动上传 → 右键单张 → 批量上传,
|
||||
并在桶里核对对象与文档回填地址可公开访问(桶需允许公开读或配 CDN)。
|
||||
- 注意:签名时钟用系统 UTC 时间,本机时钟偏差过大会被 S3 拒绝
|
||||
(报错会带回 S3 的 Code/Message,按提示校时即可)。
|
||||
84
docs/31-图片编辑体验.md
Normal file
84
docs/31-图片编辑体验.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 31 图片编辑体验(选中 / 换行 / 大小 / 链接)
|
||||
|
||||
用户真机反馈三件事:①右键图片无法编辑大小;②右键图片点「超链接」是
|
||||
空的;③图片无法选中、没法在图片后面输入文本(按回车只能在前面换行)。
|
||||
|
||||
## ③ 选中与换行(根因)
|
||||
|
||||
无头 Chrome 实测复现(真实鼠标点击):
|
||||
|
||||
- 点击图片本身能形成 NodeSelection、点图片右侧也能把光标落在图片后——
|
||||
但选中态**没有任何视觉提示**(PM 给的 `ProseMirror-selectednode`
|
||||
类没有样式),用户感知就是「选不中」。
|
||||
- 真正的死结在 Enter:图片被选中时按 Enter,默认命令链走 splitBlock,
|
||||
空段落插到图片**前面**、选区仍停在图片上;接着打字会把图片**替换掉**。
|
||||
这就是「只能在前面的文本处换行」的由来。
|
||||
|
||||
修法(`createImageEnterPlugin`,plugins.ts):
|
||||
|
||||
- 插件 view 生命周期里给编辑器 DOM 挂**捕获阶段** keydown 监听(预置
|
||||
Enter keymap 插件排在本插件之前,props.handleKeyDown 轮不到);
|
||||
只接管「图片 NodeSelection + 无修饰 Enter」:在图片所在文本块之后
|
||||
插入空段落并把光标移进去,其余 Enter 全部放行。
|
||||
- 注意 `createParagraphNear` 用不上:它对行内节点直接返回 false
|
||||
(只处理块级选区),故插入逻辑自己写(resolve 到文本块深度、
|
||||
after(depth) 处插段落)。
|
||||
- CSS 给 `img.ProseMirror-selectednode` 加 2px 主题色描边,选中可见。
|
||||
|
||||
实测(无头 Chrome,真实点击):点击→NodeSelection+描边;Enter→新段落
|
||||
在图片之后、光标在其中;打字落在新段落、图片完好。
|
||||
|
||||
## ① 图片大小
|
||||
|
||||
Markdown 没有尺寸语法,通行写法是 HTML。方案(imageSchema.ts):
|
||||
|
||||
- 包裹 preset 的 imageSchema:节点加 `width` 属性(字符串,`300`=px、
|
||||
`50%`=百分比)。
|
||||
- 解析:remark 插件(条目形状必须是 `{ plugin, options }`,裸函数不
|
||||
生效)把孤立的 `<img …>` mdast html 节点换成 image 节点,width 存
|
||||
data;根级(块级)要包一层 paragraph,行内原地替换。属性值做一遍
|
||||
实体解码(mdast 保留源码原文,如 `"`)。
|
||||
- 序列化:有 width 时发 `<img src alt title width>` html 节点(属性
|
||||
转义),无 width 时仍发标准 ``,普通文档零影响。
|
||||
- NodeView 按 width 设置 img 的 CSS 宽度(纯数字补 px)。
|
||||
- UI:右键图片 →「调整图片大小…」弹窗(数值 + 像素/百分比分段钮,
|
||||
可恢复原始大小)。带尺寸的图片在源文件里就是 HTML 形式,弹窗里
|
||||
有明示。
|
||||
|
||||
往返实测:`<img src="b.png" alt="乙" width="300">` 解析/序列化逐字
|
||||
一致;普通图片、非 img 的 HTML 均不受影响(tests/image-size.test.ts)。
|
||||
|
||||
**拖拽缩放**(同日用户追问后补):NodeView 改为包装结构
|
||||
`span.img-wrap > img + span.img-resize-handle`,图片选中时右下角出现
|
||||
手柄,拖动实时预览(只改 CSS 宽度),松手才提交节点属性(px 值,
|
||||
上限为段落宽度、下限 20px)。两个坑:
|
||||
- PM 用 mouse 事件:手柄上的 mousedown/mouseup/click 要吞掉,否则
|
||||
PM 会按点击位置捣乱;
|
||||
- `setNodeMarkup` 提交后 PM 会按 DOM 选区回读、把 NodeSelection 塌
|
||||
成文本光标(手柄随之消失、无法连续拖)——提交事务里必须同带
|
||||
`setSelection(NodeSelection)` 把选区设回去;
|
||||
`updateImageAtElement`(弹窗改尺寸/链接)同理保选区。
|
||||
无头 Chrome 真实拖拽实测:240 → 拖 +80 得 width="320"、再拖 −30 得
|
||||
"290",选区全程不丢。
|
||||
|
||||
## ② 图片超链接
|
||||
|
||||
原「超链接」是文本链接:对选区套 link 标记,预填只有 `https://`,
|
||||
对着图片点开自然是「空的」。图片的链接在 Markdown 里是包裹式
|
||||
`[](href)`——实测这在本编辑器里可往返(link 标记挂在
|
||||
图片节点上,解析/序列化都保留)。
|
||||
|
||||
修法:右键命中图片时,「超链接…」打开图片模式的链接弹窗——预填
|
||||
图片当前的包裹链接(没有就是空),提交后用一条事务对图片节点
|
||||
addMark/removeMark(editor.ts 的 `updateImageAtElement`,与尺寸
|
||||
共用);清空即移除链接。文本选区的链接流程不变。
|
||||
|
||||
配套的编辑器接口(EditorPaneApi):`imageInfoForElement`(读
|
||||
src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点
|
||||
改属性,右键菜单打开时记住元素与标签 id)。
|
||||
|
||||
## 待真机验
|
||||
|
||||
- WebView2 里点击图片的选中手感(本机为 Chromium 实测);
|
||||
- 调整大小后图片在文档中的实际观感与保存后重开的还原;
|
||||
- 图片在列表/引用/表格里时 Enter 新段落的位置是否符合预期。
|
||||
404
src-tauri/Cargo.lock
generated
404
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -19,3 +19,7 @@ serde_json = "1"
|
||||
encoding_rs = "0.8"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
trash = "5"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -258,7 +258,7 @@ fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, S
|
||||
/// 大文件会冻 UI 线程且内存整载(docs/29)。
|
||||
#[tauri::command]
|
||||
pub async fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let src = PathBuf::from(&source);
|
||||
let name = sanitize_file_name(
|
||||
&src.file_name()
|
||||
@@ -287,7 +287,7 @@ pub async fn save_bytes_to_assets(
|
||||
file_name: String,
|
||||
base64: String,
|
||||
) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let name = sanitize_file_name(&file_name);
|
||||
let bytes = base64_decode(&base64)?;
|
||||
if bytes.len() as u64 > MAX_ASSET_BYTES {
|
||||
@@ -784,7 +784,7 @@ fn git_status_inner(root: &str) -> GitStatusInfo {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_status(root: String) -> GitStatusInfo {
|
||||
tokio::task::spawn_blocking(move || git_status_inner(&root))
|
||||
tauri::async_runtime::spawn_blocking(move || git_status_inner(&root))
|
||||
.await
|
||||
.unwrap_or(GitStatusInfo {
|
||||
is_repo: false,
|
||||
@@ -799,7 +799,7 @@ pub async fn git_status(root: String) -> GitStatusInfo {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result<GitStatusInfo, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dir = Path::new(&root);
|
||||
let b = validate_branch(&branch)?;
|
||||
let url = validate_remote_url(&remote_url)?;
|
||||
@@ -833,7 +833,7 @@ pub async fn git_init_repo(root: String, remote_url: String, branch: String) ->
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatusInfo, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dir = Path::new(&root);
|
||||
let url_owned = validate_remote_url(&remote_url)?;
|
||||
let url = url_owned.as_str();
|
||||
@@ -855,7 +855,7 @@ pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatu
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcome, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dir = Path::new(&root);
|
||||
let st = git_status_inner(&root);
|
||||
if !st.is_repo {
|
||||
@@ -925,3 +925,36 @@ pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcom
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
// ————————————————— S3 图片上传(docs/30) —————————————————
|
||||
// 配置随调用从前端传入(设置弹窗收集、存本机偏好),Rust 侧不保存密钥;
|
||||
// 实现在 s3.rs(SigV4 签名已对照 AWS 官方示例向量验证)。
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_upload_image(
|
||||
config: crate::s3::S3Config,
|
||||
file_path: String,
|
||||
file_name: String,
|
||||
) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || crate::s3::upload(&config, &file_path, &file_name))
|
||||
.await
|
||||
.map_err(|e| format!("上传任务失败:{e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_upload_base64(
|
||||
config: crate::s3::S3Config,
|
||||
base64: String,
|
||||
file_name: String,
|
||||
) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || crate::s3::upload_base64(&config, &base64, &file_name))
|
||||
.await
|
||||
.map_err(|e| format!("上传任务失败:{e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))
|
||||
.await
|
||||
.map_err(|e| format!("连接测试失败:{e}"))?
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod commands;
|
||||
mod s3;
|
||||
mod search;
|
||||
mod storage;
|
||||
|
||||
@@ -66,6 +67,9 @@ pub fn run() {
|
||||
commands::git_init_repo,
|
||||
commands::git_set_remote,
|
||||
commands::git_sync_now,
|
||||
commands::s3_upload_image,
|
||||
commands::s3_upload_base64,
|
||||
commands::s3_test_connection,
|
||||
commands::delete_to_trash,
|
||||
commands::db_recent_list,
|
||||
commands::db_recent_add,
|
||||
|
||||
348
src-tauri/src/s3.rs
Normal file
348
src-tauri/src/s3.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
//! S3 兼容对象存储上传(docs/30):AWS SigV4 签名 + PUT Object。
|
||||
//! 配置由前端设置弹窗收集、随每次调用传入,Rust 侧不落盘保存密钥。
|
||||
//! endpoint 为空 = AWS S3(虚拟主机风格);填了自定义 endpoint(MinIO /
|
||||
//! R2 / OSS 等 S3 兼容服务)走路径风格 {endpoint}/{bucket}/{key}。
|
||||
//! 签名实现已对照 AWS 官方文档示例向量验证(签名值逐字一致)。
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct S3Config {
|
||||
/// 自定义服务端点(空 = AWS 官方),如 https://minio.example.com:9000
|
||||
#[serde(default)]
|
||||
pub endpoint: String,
|
||||
pub region: String,
|
||||
pub bucket: String,
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
/// 对象键前缀(可空),如 notes/
|
||||
#[serde(default)]
|
||||
pub prefix: String,
|
||||
/// 自定义访问域名(可空),如 https://files.example.com;填了则回填文档的
|
||||
/// 地址按「域名/存储桶/对象键」拼(API 地址与访问地址分离的部署,如
|
||||
/// MinIO,访问域名指向服务根、桶名在路径里);不填则用上传请求本身的地址
|
||||
#[serde(default)]
|
||||
pub public_base: String,
|
||||
}
|
||||
|
||||
const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
fn validate(cfg: &S3Config) -> Result<(), String> {
|
||||
if cfg.bucket.trim().is_empty() {
|
||||
return Err("请先在 S3 设置里填写存储桶(Bucket)。".into());
|
||||
}
|
||||
if cfg.region.trim().is_empty() {
|
||||
return Err("请先在 S3 设置里填写区域(Region)。".into());
|
||||
}
|
||||
if cfg.access_key.trim().is_empty() || cfg.secret_key.trim().is_empty() {
|
||||
return Err("请先在 S3 设置里填写 Access Key 与 Secret Key。".into());
|
||||
}
|
||||
let ep = cfg.endpoint.trim();
|
||||
if !ep.is_empty() && !ep.starts_with("http://") && !ep.starts_with("https://") {
|
||||
return Err("服务端点需以 http:// 或 https:// 开头。".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对象键:前缀(归一为无前导 /、非空时以 / 结尾)+ 文件名。
|
||||
fn object_key(cfg: &S3Config, file_name: &str) -> String {
|
||||
let prefix = cfg.prefix.trim().trim_start_matches('/');
|
||||
if prefix.is_empty() {
|
||||
file_name.to_string()
|
||||
} else if prefix.ends_with('/') {
|
||||
format!("{prefix}{file_name}")
|
||||
} else {
|
||||
format!("{prefix}/{file_name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传目标:返回 (请求 URL, 签名用 host, canonical URI)。
|
||||
fn target(cfg: &S3Config, key: &str) -> (String, String, String) {
|
||||
let encoded_key = encode_uri_path(&format!("/{}", key.trim_start_matches('/')));
|
||||
let endpoint = cfg.endpoint.trim().trim_end_matches('/');
|
||||
if endpoint.is_empty() {
|
||||
let host = format!("{}.s3.{}.amazonaws.com", cfg.bucket.trim(), cfg.region.trim());
|
||||
(format!("https://{host}{encoded_key}"), host, encoded_key)
|
||||
} else {
|
||||
let after_scheme = endpoint.split("://").nth(1).unwrap_or(endpoint);
|
||||
let host = after_scheme.split('/').next().unwrap_or(after_scheme).to_string();
|
||||
let uri = format!("/{bucket}{encoded_key}", bucket = cfg.bucket.trim());
|
||||
(format!("{endpoint}{uri}"), host, uri)
|
||||
}
|
||||
}
|
||||
|
||||
fn content_type_of(file_name: &str) -> &'static str {
|
||||
let ext = file_name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"bmp" => "image/bmp",
|
||||
"ico" => "image/x-icon",
|
||||
"avif" => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::blocking::Client, String> {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| format!("初始化网络客户端失败:{e}"))
|
||||
}
|
||||
|
||||
/// 从 S3 的 XML 错误体里粗提取 Code / Message,给用户一句能看懂的话。
|
||||
fn s3_error(status: reqwest::StatusCode, body: &str) -> String {
|
||||
let grab = |tag: &str| -> Option<String> {
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = body.find(&open)? + open.len();
|
||||
let end = body[start..].find(&close)? + start;
|
||||
Some(body[start..end].trim().to_string())
|
||||
};
|
||||
match (grab("Code"), grab("Message")) {
|
||||
(Some(code), Some(msg)) => format!("S3 返回 {status}({code}):{msg}"),
|
||||
_ => format!("S3 返回 {status}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_amz_dates() -> (String, String) {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
amz_dates(secs)
|
||||
}
|
||||
|
||||
fn signed_request(
|
||||
cfg: &S3Config,
|
||||
method: &str,
|
||||
host: &str,
|
||||
canonical_uri: &str,
|
||||
content_type: Option<&str>,
|
||||
payload_hash: &str,
|
||||
) -> (String, String, String) {
|
||||
let (amz_date, date_stamp) = now_amz_dates();
|
||||
let mut extras: Vec<(&str, &str)> = vec![("x-amz-content-sha256", payload_hash)];
|
||||
if let Some(ct) = content_type {
|
||||
extras.push(("content-type", ct));
|
||||
}
|
||||
let auth = authorization_header(&SignParams {
|
||||
method,
|
||||
host,
|
||||
canonical_uri,
|
||||
canonical_query: "",
|
||||
region: cfg.region.trim(),
|
||||
service: "s3",
|
||||
access_key: cfg.access_key.trim(),
|
||||
secret_key: cfg.secret_key.trim(),
|
||||
amz_date: &amz_date,
|
||||
date_stamp: &date_stamp,
|
||||
payload_hash,
|
||||
extra_headers: &extras,
|
||||
});
|
||||
(auth, amz_date, date_stamp)
|
||||
}
|
||||
|
||||
/// 上传一个本地文件到 S3,成功返回文档应回填的公开访问地址。
|
||||
pub fn upload(cfg: &S3Config, file_path: &str, file_name: &str) -> Result<String, String> {
|
||||
validate(cfg)?;
|
||||
let meta = std::fs::metadata(file_path)
|
||||
.map_err(|e| format!("读取待上传图片失败({file_path}):{e}"))?;
|
||||
if meta.len() > MAX_IMAGE_BYTES {
|
||||
return Err("图片超过 64MB,未上传。".into());
|
||||
}
|
||||
let bytes = std::fs::read(file_path).map_err(|e| format!("读取待上传图片失败:{e}"))?;
|
||||
upload_bytes(cfg, &bytes, file_name)
|
||||
}
|
||||
|
||||
/// 上传文档内嵌的 base64 图片(data:image/...;base64 数据,前端已剥掉前缀)。
|
||||
/// 未落盘文档粘贴的图片只有这种形态,没有文件可读。
|
||||
pub fn upload_base64(cfg: &S3Config, base64_str: &str, file_name: &str) -> Result<String, String> {
|
||||
validate(cfg)?;
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(base64_str.trim())
|
||||
.map_err(|e| format!("图片 base64 解码失败:{e}"))?;
|
||||
if bytes.len() as u64 > MAX_IMAGE_BYTES {
|
||||
return Err("图片超过 64MB,未上传。".into());
|
||||
}
|
||||
upload_bytes(cfg, &bytes, file_name)
|
||||
}
|
||||
|
||||
fn upload_bytes(cfg: &S3Config, bytes: &[u8], file_name: &str) -> Result<String, String> {
|
||||
let bytes = bytes.to_vec();
|
||||
let key = object_key(cfg, file_name);
|
||||
let (url, host, canonical_uri) = target(cfg, &key);
|
||||
let payload_hash = sha256_hex(&bytes);
|
||||
let content_type = content_type_of(file_name);
|
||||
let (auth, amz_date, _) = signed_request(cfg, "PUT", &host, &canonical_uri, Some(content_type), &payload_hash);
|
||||
let resp = client()?
|
||||
.put(&url)
|
||||
.header("Authorization", auth)
|
||||
.header("x-amz-date", amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Content-Type", content_type)
|
||||
.body(bytes)
|
||||
.send()
|
||||
.map_err(|e| format!("连接 S3 失败:{e}"))?;
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let base = cfg.public_base.trim().trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
Ok(url)
|
||||
} else {
|
||||
// 自定义访问域名按「域名/存储桶/前缀/文件名」拼(docs/30):
|
||||
// MinIO 等自建服务的 API 地址与对外访问地址是两个域名,访问域名
|
||||
// 通常指向服务根,桶名必须出现在路径里,不能沿用虚拟主机风格的省略。
|
||||
Ok(format!(
|
||||
"{base}{}",
|
||||
encode_uri_path(&format!("/{bucket}/{key}", bucket = cfg.bucket.trim()))
|
||||
))
|
||||
}
|
||||
} else {
|
||||
let body = resp.text().unwrap_or_default();
|
||||
Err(s3_error(status, &body))
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试连接:对存储桶发一个签名的 HEAD 请求,按状态码给出可读结论。
|
||||
pub fn test_connection(cfg: &S3Config) -> Result<String, String> {
|
||||
validate(cfg)?;
|
||||
let (url, host, canonical_uri) = target(cfg, "");
|
||||
// target 以空 key 生成的是「桶根」地址:虚拟主机风格即桶域名根,
|
||||
// 路径风格即 {endpoint}/{bucket}/——HEAD 桶正合适
|
||||
let payload_hash = sha256_hex(b"");
|
||||
let (auth, amz_date, _) = signed_request(cfg, "HEAD", &host, &canonical_uri, None, &payload_hash);
|
||||
let resp = client()?
|
||||
.head(&url)
|
||||
.header("Authorization", auth)
|
||||
.header("x-amz-date", amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.send()
|
||||
.map_err(|e| format!("连接 S3 失败:{e}"))?;
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
Ok("连接成功:存储桶可访问,密钥有效。".into())
|
||||
} else if status.as_u16() == 403 {
|
||||
Err("服务器返回 403:密钥不正确,或该密钥无此存储桶的访问权限。".into())
|
||||
} else if status.as_u16() == 404 {
|
||||
Err("服务器返回 404:存储桶不存在(检查桶名与区域)。".into())
|
||||
} else {
|
||||
Err(format!("服务器返回 {status}。"))
|
||||
}
|
||||
}
|
||||
|
||||
// ————————————————— SigV4 签名(已对照 AWS 官方示例向量验证) —————————————————
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(data);
|
||||
hex(&h.finalize())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC 接受任意长度密钥");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// 由 epoch 秒生成 SigV4 用的 UTC 时间串(amzDate 如 20150830T123600Z、dateStamp 如 20150830)。
|
||||
pub fn amz_dates(epoch_secs: u64) -> (String, String) {
|
||||
let days = (epoch_secs / 86_400) as i64;
|
||||
let secs = epoch_secs % 86_400;
|
||||
let (hh, mm, ss) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||
// civil_from_days(Howard Hinnant)
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = if m <= 2 { y + 1 } else { y };
|
||||
(
|
||||
format!("{year:04}{m:02}{d:02}T{hh:02}{mm:02}{ss:02}Z"),
|
||||
format!("{year:04}{m:02}{d:02}"),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct SignParams<'a> {
|
||||
pub method: &'a str,
|
||||
pub host: &'a str,
|
||||
pub canonical_uri: &'a str,
|
||||
pub canonical_query: &'a str,
|
||||
pub region: &'a str,
|
||||
pub service: &'a str,
|
||||
pub access_key: &'a str,
|
||||
pub secret_key: &'a str,
|
||||
pub amz_date: &'a str,
|
||||
pub date_stamp: &'a str,
|
||||
pub payload_hash: &'a str,
|
||||
/// 除 host / x-amz-date 外参与签名的请求头(名须小写)
|
||||
pub extra_headers: &'a [(&'a str, &'a str)],
|
||||
}
|
||||
|
||||
/// 计算 Authorization 头的完整值。
|
||||
pub fn authorization_header(p: &SignParams) -> String {
|
||||
let mut headers: Vec<(&str, &str)> = vec![("host", p.host), ("x-amz-date", p.amz_date)];
|
||||
headers.extend_from_slice(p.extra_headers);
|
||||
headers.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let canonical_headers: String = headers
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}:{}\n", v.trim()))
|
||||
.collect();
|
||||
let signed_headers: String = headers
|
||||
.iter()
|
||||
.map(|(k, _)| *k)
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
let canonical_request = format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
p.method, p.canonical_uri, p.canonical_query, canonical_headers, signed_headers, p.payload_hash
|
||||
);
|
||||
let scope = format!("{}/{}/{}/aws4_request", p.date_stamp, p.region, p.service);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
p.amz_date,
|
||||
scope,
|
||||
sha256_hex(canonical_request.as_bytes())
|
||||
);
|
||||
let k_date = hmac(format!("AWS4{}", p.secret_key).as_bytes(), p.date_stamp.as_bytes());
|
||||
let k_region = hmac(&k_date, p.region.as_bytes());
|
||||
let k_service = hmac(&k_region, p.service.as_bytes());
|
||||
let k_signing = hmac(&k_service, b"aws4_request");
|
||||
let signature = hex(&hmac(&k_signing, string_to_sign.as_bytes()));
|
||||
format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
p.access_key, scope, signed_headers, signature
|
||||
)
|
||||
}
|
||||
|
||||
/// URI 路径编码(SigV4 canonical URI 与公开地址共用):保留 / 与 RFC 3986 非保留字符。
|
||||
pub fn encode_uri_path(path: &str) -> String {
|
||||
let mut out = String::with_capacity(path.len());
|
||||
for b in path.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}
|
||||
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false},"wix":{"language":"zh-CN"}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}
|
||||
314
src/App.tsx
314
src/App.tsx
@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
|
||||
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
|
||||
import { insertPlainText, insertLinkText } from './editor/paste'
|
||||
import { isImeComposing } from './utils/ime'
|
||||
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals'
|
||||
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal } from './components/Modals'
|
||||
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
|
||||
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
|
||||
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
|
||||
@@ -29,13 +29,15 @@ import {
|
||||
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
||||
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
||||
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
||||
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, type GitStatusInfo,
|
||||
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo,
|
||||
} from './services/platform'
|
||||
import * as store from './services/store'
|
||||
import { recoveryKey, planRecovery } from './utils/recovery'
|
||||
import { appWindowClaims, appWindowId } from './services/windowClaims'
|
||||
import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync'
|
||||
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 { AppearanceModal, ExportModal } from './components/Stage5Modals'
|
||||
import { DOC_THEMES, docThemeById } from './themes'
|
||||
@@ -61,11 +63,13 @@ const UI_KEY = 'new-markdown:ui'
|
||||
function loadRecent(): string[] {
|
||||
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 {
|
||||
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 {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -472,11 +476,32 @@ export default function App() {
|
||||
|
||||
const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder])
|
||||
|
||||
const saveTab = useCallback(async (target: DocTab, saveAs: boolean): Promise<boolean> => {
|
||||
// 非 UTF-8 文档保存转码提示(docs/29 D1,用户拍板加):按路径记已确认,
|
||||
// 本次运行内同一文件只提示一次;确认后走 finishSave 完成落盘收尾。
|
||||
const encodingAckedRef = useRef<Set<string>>(new Set())
|
||||
const [encodingPrompt, setEncodingPrompt] = useState<{ target: DocTab; path: string; snapshot: string } | null>(null)
|
||||
|
||||
/** 保存落盘后的收尾:标已保存(编码如实记为 UTF-8)、刷树、更新基线、清恢复记录。 */
|
||||
const finishSave = useCallback(async (target: DocTab, path: string, snapshot: string) => {
|
||||
const oldKey = recoveryKey(target)
|
||||
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path), encoding: 'UTF-8' })
|
||||
// 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树
|
||||
if (target.path !== path) setTreeRefresh((n) => n + 1)
|
||||
const stat = await statFile(path)
|
||||
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
|
||||
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
|
||||
const newKey = recoveryKey({ id: target.id, path })
|
||||
for (const key of new Set([oldKey, newKey])) {
|
||||
recoveryWrittenRef.current.delete(key)
|
||||
void store.deleteRecovery(key)
|
||||
}
|
||||
pushRecent(path)
|
||||
}, [pushRecent])
|
||||
|
||||
const saveTab = useCallback(async (target: DocTab, saveAs: boolean, fromAutosave = false): Promise<boolean> => {
|
||||
// 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存,
|
||||
// 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。
|
||||
const snapshot = target.content
|
||||
const oldKey = recoveryKey(target)
|
||||
try {
|
||||
if (!tauriMode) {
|
||||
downloadTextFile(`${target.title}.md`, snapshot)
|
||||
@@ -498,25 +523,35 @@ export default function App() {
|
||||
path = await pickSaveFile(path ?? (workspaceRoot ? joinPath(workspaceRoot, `${target.title}.md`) : `${target.title}.md`))
|
||||
if (!path) return false
|
||||
}
|
||||
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
|
||||
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) })
|
||||
// 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树
|
||||
if (target.path !== path) setTreeRefresh((n) => n + 1)
|
||||
const stat = await statFile(path)
|
||||
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
|
||||
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
|
||||
const newKey = recoveryKey({ id: target.id, path })
|
||||
for (const key of new Set([oldKey, newKey])) {
|
||||
recoveryWrittenRef.current.delete(key)
|
||||
void store.deleteRecovery(key)
|
||||
// 非 UTF-8 文档保存会转码为 UTF-8:首次保存前提示一次(按路径记已确认)。
|
||||
// 自动保存不弹窗、也不静默转码——跳过本轮,等手动保存/关窗保存时提示。
|
||||
if (target.encoding !== 'UTF-8' && !encodingAckedRef.current.has(path)) {
|
||||
if (fromAutosave) return false
|
||||
setEncodingPrompt({ target, path, snapshot })
|
||||
return false
|
||||
}
|
||||
pushRecent(path)
|
||||
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
|
||||
await finishSave(target, path, snapshot)
|
||||
return true
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
return false
|
||||
}
|
||||
}, [tauriMode, pushRecent, workspaceRoot])
|
||||
}, [tauriMode, finishSave, workspaceRoot])
|
||||
|
||||
/** 编码提示确认:记下该路径已确认,用弹窗时冻结的快照完成落盘。 */
|
||||
const confirmEncodingSave = useCallback(async () => {
|
||||
const job = encodingPrompt
|
||||
if (!job) return
|
||||
setEncodingPrompt(null)
|
||||
encodingAckedRef.current.add(job.path)
|
||||
try {
|
||||
await writeTextFile(job.path, toDiskContent(job.snapshot, job.target.lineEnding), job.target.hadBom)
|
||||
await finishSave(job.target, job.path, job.snapshot)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
}, [encodingPrompt, finishSave])
|
||||
|
||||
const requestClose = useCallback((ids: string[], after: () => void) => {
|
||||
const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t))
|
||||
@@ -636,7 +671,11 @@ export default function App() {
|
||||
// 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。
|
||||
const uiJson = await store.loadUiPref()
|
||||
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
|
||||
const recentFromDb = await store.loadRecent()
|
||||
@@ -731,7 +770,7 @@ export default function App() {
|
||||
const timer = window.setInterval(() => {
|
||||
for (const t of stateRef.current.tabs) {
|
||||
// 冲突弹窗正等用户裁决的标签不自动保存,否则会抢先覆盖外部修改
|
||||
if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false)
|
||||
if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false, true)
|
||||
}
|
||||
}, Math.max(10, ui.autosaveSeconds) * 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
@@ -1047,7 +1086,10 @@ 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; href: string } | null>(null)
|
||||
const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null)
|
||||
const openEditorMenu = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const inEditor = sourceMode
|
||||
@@ -1056,15 +1098,25 @@ export default function App() {
|
||||
if (!inEditor) return
|
||||
e.preventDefault()
|
||||
let hasSelection = false
|
||||
let imageSrc: string | null = null
|
||||
let imageEl: HTMLElement | null = null
|
||||
let imageTabId: string | null = null
|
||||
if (sourceMode) {
|
||||
hasSelection = sourceApiRef.current?.hasSelection() ?? false
|
||||
} else if (tab) {
|
||||
const editor = paneApisRef.current.get(tab.id)?.editor
|
||||
if (editor) {
|
||||
try { hasSelection = editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false }
|
||||
const api = paneApisRef.current.get(tab.id)
|
||||
if (api?.editor) {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection })
|
||||
setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection, imageSrc, imageEl, tabId: imageTabId })
|
||||
}
|
||||
const runEditorMenuAction = (action: EditorMenuAction) => {
|
||||
switch (action) {
|
||||
@@ -1076,8 +1128,28 @@ export default function App() {
|
||||
case 'italic': withEditor(editorCommands.italic); break
|
||||
case 'strikethrough': withEditor(editorCommands.strikethrough); break
|
||||
case 'inlineCode': withEditor(editorCommands.inlineCode); break
|
||||
case 'link': setModal('link'); break
|
||||
case 'link': {
|
||||
// 右键的是图片:链接弹窗切图片模式——预填图片当前的包裹链接,
|
||||
// 提交后把链接标记挂到图片节点上(序列化为 [](href))
|
||||
const el = editorMenu?.imageEl
|
||||
const tid = editorMenu?.tabId
|
||||
if (el && tid) {
|
||||
const info = paneApisRef.current.get(tid)?.imageInfoForElement(el)
|
||||
setImageLinkTarget({ tabId: tid, el, 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 'uploadS3': { const src = editorMenu?.imageSrc; if (src) void runSingleImageUpload(src); break }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1097,6 +1169,8 @@ export default function App() {
|
||||
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
const uiRef = useRef(ui)
|
||||
uiRef.current = ui
|
||||
|
||||
// 活动标签变化时登记保活顺序(最近用过的排前面,最多 6 个;超出的卸载走最终回写同步)
|
||||
const activeIdForAlive = tab?.id
|
||||
@@ -1124,21 +1198,114 @@ export default function App() {
|
||||
}
|
||||
}, [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。 */
|
||||
const handleImageFile = useCallback(async (file: File): Promise<string> => {
|
||||
const current = activeTab(stateRef.current)
|
||||
if (isTauri() && current?.path) {
|
||||
const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg')
|
||||
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()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => resolve(URL.createObjectURL(file))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}, [])
|
||||
// 未落盘文档的粘贴图是 base64 内嵌形态:开了自动上传同样传走并回换
|
||||
if (current && dataUrl.startsWith('data:')) autoUploadSwap(current.id, current.path ?? null, dataUrl)
|
||||
return dataUrl
|
||||
}, [autoUploadSwap])
|
||||
|
||||
const insertImage = useCallback(async () => {
|
||||
if (!tab) return
|
||||
@@ -1149,12 +1316,13 @@ export default function App() {
|
||||
try {
|
||||
const rel = await copyToAssets(picked, tab.path)
|
||||
withEditor((e) => editorCommands.image(e, rel, baseName(picked)))
|
||||
autoUploadSwap(tab.id, tab.path, rel)
|
||||
} catch (e) { setError(String(e)) }
|
||||
} else {
|
||||
const picked = await pickAndReadWebImage()
|
||||
if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name))
|
||||
}
|
||||
}, [tab, tauriMode, withEditor])
|
||||
}, [tab, tauriMode, withEditor, autoUploadSwap])
|
||||
|
||||
const insertAttachment = useCallback(async () => {
|
||||
if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return }
|
||||
@@ -1363,7 +1531,7 @@ export default function App() {
|
||||
}, [tauriMode, flash])
|
||||
|
||||
anyModalRef.current = Boolean(
|
||||
modal || appearanceOpen || exportOpen || pendingClose || conflict
|
||||
modal || appearanceOpen || exportOpen || pendingClose || conflict || encodingPrompt
|
||||
|| promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
||||
)
|
||||
findOpenRef.current = findUi.open
|
||||
@@ -1376,6 +1544,16 @@ export default function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[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(() => {
|
||||
if (!tab) return { words: 0, chars: 0, lines: 0 }
|
||||
return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length }
|
||||
@@ -1399,6 +1577,9 @@ export default function App() {
|
||||
{ label: '导入 HTML / Word…', onClick: () => void importFileFlow() },
|
||||
{ label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) },
|
||||
{ label: '打印 / 导出 PDF…', shortcut: 'Ctrl+P', disabled: !tab, onClick: printDoc },
|
||||
{ divider: true },
|
||||
{ label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() },
|
||||
{ label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1703,6 +1884,8 @@ export default function App() {
|
||||
sourceMode={sourceMode}
|
||||
tauriMode={tauriMode}
|
||||
zoom={zoom}
|
||||
pendingImages={pendingImages}
|
||||
onUploadImages={() => void runBatchImageUpload()}
|
||||
/>
|
||||
{gitSettingsOpen && workspaceRoot && (
|
||||
<GitSyncModal
|
||||
@@ -1716,6 +1899,33 @@ export default function App() {
|
||||
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 && (
|
||||
<AppearanceModal
|
||||
docTheme={ui.docTheme}
|
||||
@@ -1754,6 +1964,7 @@ export default function App() {
|
||||
x={editorMenu.x}
|
||||
y={editorMenu.y}
|
||||
hasSelection={editorMenu.hasSelection}
|
||||
imageSrc={editorMenu.imageSrc}
|
||||
sourceMode={sourceMode}
|
||||
onAction={runEditorMenuAction}
|
||||
onClose={() => setEditorMenu(null)}
|
||||
@@ -1856,7 +2067,44 @@ export default function App() {
|
||||
onSubmit={(href) => { setModal(null); if (href) withEditor((e) => editorCommands.link(e, href)) }}
|
||||
/>
|
||||
)}
|
||||
{imageLinkTarget && (
|
||||
<LinkModal
|
||||
heading="图片链接"
|
||||
hint="给图片加链接,点开图片时跳转到这个地址;清空则移除链接。"
|
||||
initialHref={imageLinkTarget.href}
|
||||
onCancel={() => setImageLinkTarget(null)}
|
||||
onSubmit={(href) => {
|
||||
const t = imageLinkTarget
|
||||
setImageLinkTarget(null)
|
||||
const ok = paneApisRef.current.get(t.tabId)?.updateImageForElement(t.el, { linkHref: href || null })
|
||||
if (ok) flash(href ? '图片链接已更新' : '图片链接已移除')
|
||||
else setError('图片已不在文档中,链接未更新。')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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)} />}
|
||||
{encodingPrompt && (
|
||||
<EncodingConvertModal
|
||||
encoding={encodingPrompt.target.encoding}
|
||||
fileName={baseName(encodingPrompt.path)}
|
||||
onConfirm={() => { void confirmEncodingSave() }}
|
||||
onCancel={() => setEncodingPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
||||
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
||||
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
// 展开层经 Portal 挂到 body 并用 fixed 定位(按触发钮 rect 计算、下方空间
|
||||
// 不足时向上翻):弹窗等带 overflow 的容器里 absolute 定位会被滚动盒裁掉
|
||||
// (Git 同步设置里实测被遮挡),Portal + fixed 才能真正逃出裁剪。
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
|
||||
export interface DropdownOption { value: string; label: string }
|
||||
|
||||
interface MenuPos { left: number; top: number; width: number; maxHeight: number }
|
||||
|
||||
const ITEM_H = 31
|
||||
const MENU_PAD = 8
|
||||
const MAX_H = 264
|
||||
|
||||
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
value: string
|
||||
options: DropdownOption[]
|
||||
@@ -14,13 +24,35 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<MenuPos | null>(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const close = useCallback(() => setOpen(false), [])
|
||||
useDismiss(ref, open, close)
|
||||
const dismissRefs = useMemo(() => [ref, menuRef], [])
|
||||
useDismiss(dismissRefs, open, close)
|
||||
|
||||
const current = options.find((o) => o.value === value) ?? options[0]
|
||||
|
||||
// 打开时按触发钮位置算展开层几何:优先向下,下方放不下且上方更宽裕时翻上去
|
||||
const openMenu = useCallback(() => {
|
||||
const rect = btnRef.current?.getBoundingClientRect()
|
||||
if (!rect) { setOpen(true); return }
|
||||
const est = Math.min(MAX_H, options.length * ITEM_H + MENU_PAD)
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8
|
||||
const spaceAbove = rect.top - 8
|
||||
const above = spaceBelow < est && spaceAbove > spaceBelow
|
||||
const maxHeight = Math.max(80, Math.min(MAX_H, above ? spaceAbove : spaceBelow))
|
||||
const height = Math.min(est, maxHeight)
|
||||
setPos({
|
||||
left: Math.max(4, Math.min(rect.left, window.innerWidth - rect.width - 4)),
|
||||
top: above ? Math.max(4, rect.top - height - 4) : rect.bottom + 4,
|
||||
width: rect.width,
|
||||
maxHeight,
|
||||
})
|
||||
setOpen(true)
|
||||
}, [options.length])
|
||||
|
||||
// 打开时把焦点放到选中项,便于直接上下键选行
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -29,6 +61,14 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open ])
|
||||
|
||||
// 窗口尺寸变化时展开层位置失效,直接关闭(与菜单类组件惯例一致)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onResize = () => setOpen(false)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [open ])
|
||||
|
||||
const pick = (v: string) => { setOpen(false); onChange(v) }
|
||||
|
||||
const onMenuKey = (e: React.KeyboardEvent) => {
|
||||
@@ -46,17 +86,23 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
<div className="dropdown" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
className="dropdown-button"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onClick={() => { if (open) setOpen(false); else openMenu() }}
|
||||
>
|
||||
<span className="dropdown-label">{current?.label ?? ''}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}>
|
||||
{open && pos && createPortal(
|
||||
<div
|
||||
className="dropdown-menu"
|
||||
ref={menuRef}
|
||||
onKeyDown={onMenuKey}
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight, zIndex: 200 }}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
@@ -67,7 +113,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useRef } from 'react'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
import { clampMenuPosition } from '../utils/menuPos'
|
||||
import { isUploadableImageSrc } from '../services/s3'
|
||||
|
||||
export type EditorMenuAction =
|
||||
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||
| 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link'
|
||||
| 'toggleSource'
|
||||
| 'toggleSource' | 'uploadS3' | 'imageSize'
|
||||
|
||||
interface Props {
|
||||
x: number
|
||||
y: number
|
||||
/** 当前是否有选区(决定剪切/复制是否可用) */
|
||||
hasSelection: boolean
|
||||
/** 右键命中的图片节点 src(非图片为 null);本地未上传图时多一项「上传到 S3」 */
|
||||
imageSrc: string | null
|
||||
sourceMode: boolean
|
||||
onAction: (action: EditorMenuAction) => void
|
||||
onClose: () => void
|
||||
@@ -22,12 +25,13 @@ interface Props {
|
||||
* 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里,
|
||||
* execCommand 类命令才作用得准。
|
||||
*/
|
||||
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)
|
||||
useDismiss(ref, true, onClose)
|
||||
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
|
||||
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式)
|
||||
const { left, top } = clampMenuPosition(x, y, 188, sourceMode ? 236 : 388)
|
||||
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
|
||||
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项)
|
||||
const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0) + (imageSrc ? 40 : 0))
|
||||
return (
|
||||
<div
|
||||
className="tree-context-menu editor-context-menu"
|
||||
@@ -51,6 +55,13 @@ export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAc
|
||||
<button onClick={() => act('link')}>超链接…</button>
|
||||
</>
|
||||
)}
|
||||
{(showUpload || imageSrc) && (
|
||||
<>
|
||||
<div className="tree-menu-divider" />
|
||||
{showUpload && <button onClick={() => act('uploadS3')}>上传到 S3</button>}
|
||||
{imageSrc && <button onClick={() => act('imageSize')}>调整图片大小…</button>}
|
||||
</>
|
||||
)}
|
||||
<div className="tree-menu-divider" />
|
||||
<button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import TableToolbar from './TableToolbar'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
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, type CursorInfo, type ImageNodeInfo, type ImageNodePatch } from '../editor/editor'
|
||||
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
|
||||
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
|
||||
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
|
||||
@@ -19,6 +19,16 @@ export interface EditorPaneApi {
|
||||
replaceCurrent: (replacement: string) => boolean
|
||||
replaceAll: (replacement: string) => number
|
||||
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
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -48,6 +58,11 @@ const EMPTY_API: EditorPaneApi = {
|
||||
replaceCurrent: () => false,
|
||||
replaceAll: () => 0,
|
||||
clearFind: () => undefined,
|
||||
listImages: () => [],
|
||||
replaceImageSrc: () => 0,
|
||||
imageInfoForElement: () => null,
|
||||
updateImageForElement: () => false,
|
||||
imageSrcForElement: () => null,
|
||||
}
|
||||
|
||||
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
|
||||
@@ -231,6 +246,15 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
const clearFind = () => {
|
||||
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)
|
||||
editor.create()
|
||||
.then(() => {
|
||||
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
|
||||
@@ -241,7 +265,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
try {
|
||||
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
|
||||
} 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 })
|
||||
if (chunkLoad) {
|
||||
setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
|
||||
void chunkLoad.appendRest(
|
||||
|
||||
@@ -45,7 +45,7 @@ export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialInterval
|
||||
<input
|
||||
className="input"
|
||||
value={remoteUrl}
|
||||
placeholder="https://git.xhome.cc/xieyinlu/notes.git"
|
||||
placeholder="https://github.com/example/example.git"
|
||||
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -28,6 +28,24 @@ export function ConfirmSaveModal({ title, onSave, onDiscard, onCancel }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function EncodingConvertModal({ encoding, fileName, onConfirm, onCancel }: {
|
||||
encoding: string
|
||||
fileName: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<Shell onCancel={onCancel}>
|
||||
<h3>文档编码将转为 UTF-8</h3>
|
||||
<p>「{fileName}」原是 {encoding} 编码。本编辑器统一以 UTF-8 保存,保存后文件编码将转为 UTF-8(内容不变)。本次运行内,同一文件只提示这一次。</p>
|
||||
<div className="modal-actions">
|
||||
<button onClick={onCancel}>取消保存</button>
|
||||
<button className="primary" onClick={onConfirm}>转为 UTF-8 保存</button>
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
export function AboutModal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Shell onCancel={onClose}>
|
||||
@@ -122,11 +140,19 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function LinkModal({ onSubmit, onCancel }: { onSubmit: (href: string) => void; onCancel: () => void }) {
|
||||
const [href, setHref] = useState('https://')
|
||||
export function LinkModal({ onSubmit, onCancel, initialHref, heading, hint }: {
|
||||
onSubmit: (href: string) => void
|
||||
onCancel: () => void
|
||||
/** 预填地址(编辑已有链接时用);缺省为 https:// 前缀 */
|
||||
initialHref?: string
|
||||
heading?: string
|
||||
hint?: string
|
||||
}) {
|
||||
const [href, setHref] = useState(initialHref ?? 'https://')
|
||||
return (
|
||||
<Shell onCancel={onCancel}>
|
||||
<h3>插入链接</h3>
|
||||
<h3>{heading ?? '插入链接'}</h3>
|
||||
{hint && <p className="modal-note">{hint}</p>}
|
||||
<input
|
||||
className="modal-input"
|
||||
value={href}
|
||||
@@ -143,6 +169,54 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
|
||||
return (
|
||||
<Shell onCancel={onClose}>
|
||||
|
||||
132
src/components/S3SettingsModal.tsx
Normal file
132
src/components/S3SettingsModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
// S3 图片上传设置(docs/30):服务端点/区域/存储桶/密钥/前缀/访问域名,
|
||||
// 以及「自动上传图片」开关。密钥只保存在本机偏好库里,随调用传给 Rust
|
||||
// 签名上传,不写进任何文档、也不会被 Git 同步带走。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { s3TestConnection } from '../services/platform'
|
||||
import { s3ConfigPayload, type S3Settings } from '../services/s3'
|
||||
|
||||
export function S3SettingsModal({ initial, onSave, onClose }: {
|
||||
initial: S3Settings
|
||||
onSave: (next: S3Settings) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState<S3Settings>(initial)
|
||||
const [testState, setTestState] = useState<{ kind: 'idle' | 'busy' | 'ok' | 'err'; text: string }>({ kind: 'idle', text: '' })
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const set = (patch: Partial<S3Settings>) => {
|
||||
setDraft((d) => ({ ...d, ...patch }))
|
||||
setTestState({ kind: 'idle', text: '' })
|
||||
}
|
||||
|
||||
const test = async () => {
|
||||
setTestState({ kind: 'busy', text: '正在连接…' })
|
||||
try {
|
||||
const msg = await s3TestConnection(s3ConfigPayload(draft))
|
||||
setTestState({ kind: 'ok', text: msg })
|
||||
} catch (e) {
|
||||
setTestState({ kind: 'err', text: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>S3 图片上传设置</h3>
|
||||
<p className="modal-note">
|
||||
图片上传到你自己的 S3 兼容存储(AWS S3、MinIO、Cloudflare R2、阿里云 OSS 等),
|
||||
文档里的图片引用随之换成公开访问地址。密钥只保存在本机设置里,不会写进文档。
|
||||
</p>
|
||||
<label className="modal-field">
|
||||
<span>服务端点(Endpoint,可空 = AWS S3)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.endpoint}
|
||||
placeholder="https://s3.example.com"
|
||||
onChange={(e) => set({ endpoint: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="modal-field">
|
||||
<span>区域(Region)*</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.region}
|
||||
placeholder="us-east-1"
|
||||
onChange={(e) => set({ region: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="modal-field">
|
||||
<span>存储桶(Bucket)*</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.bucket}
|
||||
placeholder="my-images"
|
||||
onChange={(e) => set({ bucket: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="modal-field">
|
||||
<span>Access Key *</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.accessKey}
|
||||
autoComplete="off"
|
||||
onChange={(e) => set({ accessKey: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="modal-field">
|
||||
<span>Secret Key *</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={draft.secretKey}
|
||||
autoComplete="off"
|
||||
onChange={(e) => set({ secretKey: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="modal-field">
|
||||
<span>路径前缀(可空,对象键 = 前缀 + 文件名)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.prefix}
|
||||
placeholder="notes/"
|
||||
onChange={(e) => set({ prefix: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="modal-field">
|
||||
<span>自定义访问域名(可空;回填地址 = 此域名 / 存储桶 / 前缀 / 文件名,适合 MinIO 等 API 与访问地址分离的部署)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.publicBase}
|
||||
placeholder="https://cdn.example.com"
|
||||
onChange={(e) => set({ publicBase: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="setting-row">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.autoUpload}
|
||||
onChange={(e) => set({ autoUpload: e.target.checked })}
|
||||
/>
|
||||
自动上传图片(粘贴/插入的图片落盘后自动上传,并换成 S3 地址)
|
||||
</label>
|
||||
</div>
|
||||
{testState.kind !== 'idle' && (
|
||||
<p className="modal-note" style={testState.kind === 'err' ? { color: 'var(--danger, #c0392b)' } : undefined}>
|
||||
{testState.text}
|
||||
</p>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={() => void test()} disabled={testState.kind === 'busy'}>测试连接</button>
|
||||
<button type="button" onClick={onClose}>取消</button>
|
||||
<button type="button" className="primary" onClick={() => onSave(draft)}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,9 @@ interface Props {
|
||||
sourceMode: boolean
|
||||
tauriMode: boolean
|
||||
zoom: number
|
||||
/** 当前文档未上传的本地图片数(>0 时状态栏出批量上传钮) */
|
||||
pendingImages?: number
|
||||
onUploadImages?: () => void
|
||||
}
|
||||
|
||||
export default function StatusBar(p: Props) {
|
||||
@@ -25,6 +28,11 @@ export default function StatusBar(p: Props) {
|
||||
<span>{p.dirty ? '未保存' : '已保存'}</span>
|
||||
<span>{p.sourceMode ? '源代码模式' : '即时渲染'}</span>
|
||||
<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>{p.tauriMode ? '桌面模式' : 'Web 降级模式(调试)'}</span>
|
||||
</span>
|
||||
|
||||
@@ -12,8 +12,10 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard'
|
||||
import { indent } from '@milkdown/kit/plugin/indent'
|
||||
import { math } from '@milkdown/plugin-math'
|
||||
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 { extendImageSchema } from './imageSchema'
|
||||
|
||||
export interface CursorInfo { line: number; col: number }
|
||||
|
||||
@@ -39,6 +41,8 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
||||
ctx.set(defaultValueCtx, initialMarkdown)
|
||||
// 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-`
|
||||
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
|
||||
// 图片尺寸扩展(docs/31):width 属性 + <img> HTML 往返
|
||||
extendImageSchema(ctx)
|
||||
const lm = ctx.get(listenerCtx)
|
||||
if (callbacks.onMarkdown) {
|
||||
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
|
||||
@@ -109,6 +113,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
||||
.use(math)
|
||||
.use(upload)
|
||||
.use(createPlainTextPastePlugin())
|
||||
.use(createImageEnterPlugin())
|
||||
if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay))
|
||||
return editor
|
||||
}
|
||||
@@ -165,6 +170,145 @@ export function getMarkdown(editor: Editor): string {
|
||||
})
|
||||
}
|
||||
|
||||
/** 文档里全部图片的 src(按出现顺序、去重)——S3 批量上传扫描用(docs/30)。 */
|
||||
export function collectImageSrcs(editor: Editor): string[] {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
view.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'image') {
|
||||
const src = String(node.attrs.src ?? '')
|
||||
if (src && !seen.has(src)) { seen.add(src); out.push(src) }
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 把文档里所有 src === oldSrc 的图片节点换成 newSrc,返回替换数。
|
||||
* 一次遍历收集位置、同一事务内 setNodeMarkup:image 是叶子节点,
|
||||
* 改属性不改变文档尺寸,位置不会失效。
|
||||
*/
|
||||
export function replaceImageSrc(editor: Editor, oldSrc: string, newSrc: string): number {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const positions: number[] = []
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'image' && String(node.attrs.src ?? '') === oldSrc) positions.push(pos)
|
||||
return true
|
||||
})
|
||||
if (positions.length === 0) return 0
|
||||
const tr = view.state.tr
|
||||
for (const pos of positions) {
|
||||
const node = view.state.doc.nodeAt(pos)
|
||||
if (node) tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc })
|
||||
}
|
||||
view.dispatch(tr)
|
||||
return positions.length
|
||||
})
|
||||
}
|
||||
|
||||
/** 右键命中的 <img> 元素对应的图片节点 src(非图片返回 null)——右键菜单判「本地未上传图」用。 */
|
||||
export function imageSrcAtElement(editor: Editor, el: HTMLElement): string | null {
|
||||
try {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const pos = view.posAtDOM(el, 0)
|
||||
const node = view.state.doc.nodeAt(pos)
|
||||
if (!node || node.type.name !== 'image') return null
|
||||
return String(node.attrs.src ?? '') || null
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImageNodeInfo {
|
||||
src: string
|
||||
alt: string
|
||||
/** 尺寸('300' = px、'50%' = 百分比);未设置时为 null */
|
||||
width: string | null
|
||||
/** 图片被链接包裹时的链接地址(链接标记的 href);没有时为 null */
|
||||
linkHref: string | null
|
||||
}
|
||||
|
||||
export interface ImageNodePatch {
|
||||
src?: string
|
||||
alt?: string
|
||||
width?: string | null
|
||||
/** undefined = 不动链接;null/'' = 去掉链接;字符串 = 设置/更新链接 */
|
||||
linkHref?: string | null
|
||||
}
|
||||
|
||||
function imagePosAtElement(view: { posAtDOM: (el: Node, off: number) => number; state: { doc: { nodeAt: (pos: number) => unknown } } }, el: HTMLElement): number {
|
||||
const pos = view.posAtDOM(el, 0)
|
||||
const node = view.state.doc.nodeAt(pos) as { type: { name: string } } | null
|
||||
if (!node || node.type.name !== 'image') return -1
|
||||
return pos
|
||||
}
|
||||
|
||||
/** 读某个 <img> 元素对应图片节点的属性(含尺寸与包裹链接),非图片返回 null。 */
|
||||
export function imageInfoAtElement(editor: Editor, el: HTMLElement): ImageNodeInfo | null {
|
||||
try {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const pos = imagePosAtElement(view, el)
|
||||
if (pos < 0) return null
|
||||
const node = view.state.doc.nodeAt(pos)
|
||||
if (!node) return null
|
||||
const link = node.marks.find((m) => m.type.name === 'link')
|
||||
return {
|
||||
src: String(node.attrs.src ?? ''),
|
||||
alt: String(node.attrs.alt ?? ''),
|
||||
width: (node.attrs.width as string | null) ?? null,
|
||||
linkHref: link ? String(link.attrs.href ?? '') || null : null,
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 改某个 <img> 元素对应图片节点的属性(地址/替代文本/尺寸/包裹链接),
|
||||
* 一次事务完成。链接以标记形式挂在图片节点上,序列化为 [](href)。
|
||||
*/
|
||||
export function updateImageAtElement(editor: Editor, el: HTMLElement, patch: ImageNodePatch): boolean {
|
||||
try {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const pos = imagePosAtElement(view, el)
|
||||
if (pos < 0) return false
|
||||
const node = view.state.doc.nodeAt(pos)
|
||||
if (!node) return false
|
||||
const tr = view.state.tr
|
||||
const attrs = { ...node.attrs }
|
||||
if (patch.src !== undefined) attrs.src = patch.src
|
||||
if (patch.alt !== undefined) attrs.alt = patch.alt
|
||||
if (patch.width !== undefined) attrs.width = patch.width
|
||||
tr.setNodeMarkup(pos, undefined, attrs)
|
||||
if (patch.linkHref !== undefined) {
|
||||
const linkType = view.state.schema.marks.link
|
||||
if (linkType) {
|
||||
tr.removeMark(pos, pos + 1, linkType)
|
||||
if (patch.linkHref) tr.addMark(pos, pos + 1, linkType.create({ href: patch.linkHref }))
|
||||
}
|
||||
}
|
||||
// 同 plugins 的拖拽提交:换节点属性后保住节点选区,别被回读塌掉
|
||||
if (view.state.selection instanceof NodeSelection && view.state.selection.from === pos) {
|
||||
tr.setSelection(NodeSelection.create(tr.doc, pos))
|
||||
}
|
||||
if (tr.docChanged) view.dispatch(tr)
|
||||
return true
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function callUndo(editor: Editor) {
|
||||
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
|
||||
}
|
||||
|
||||
128
src/editor/imageSchema.ts
Normal file
128
src/editor/imageSchema.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// 图片尺寸扩展(docs/31):给 Milkdown 图片节点加 width 属性,让「右键 →
|
||||
// 调整大小」可持久化。Markdown 本身没有图片尺寸语法,业界通行写法是
|
||||
// HTML:带 width 的图片序列化为 <img src alt width>,无 width 时仍是
|
||||
// 标准 ,不污染普通文档。
|
||||
// - 解析:remark 阶段把孤立的 <img …>(行内或块级)mdast html 节点换成
|
||||
// image 节点并把 width 放进 data;图片 schema 的 parseMarkdown runner
|
||||
// 读出来落到节点 attrs。
|
||||
// - 序列化:toMarkdown runner 见 width 就发 html 节点,否则原样发 image。
|
||||
// 往返已实测:<img src="b.png" alt="乙" width="300"> 解析/序列化逐字一致。
|
||||
import type { Ctx } from '@milkdown/kit/ctx'
|
||||
import { remarkPluginsCtx } from '@milkdown/kit/core'
|
||||
import { imageSchema } from '@milkdown/kit/preset/commonmark'
|
||||
|
||||
const IMG_TAG = /^<img\s+[^>]*\/?>$/i
|
||||
|
||||
/** 解析一个孤立 <img> 标签的属性表;不是孤立 img 或没有 src 时返回 null。 */
|
||||
export function parseImgTag(html: string): Record<string, string> | null {
|
||||
const t = html.trim()
|
||||
if (!IMG_TAG.test(t)) return null
|
||||
const attrs: Record<string, string> = {}
|
||||
for (const m of t.matchAll(/([\w-]+)\s*=\s*"([^"]*)"|([\w-]+)\s*=\s*'([^']*)'/g)) {
|
||||
attrs[(m[1] ?? m[3]).toLowerCase()] = decodeAttr(m[2] ?? m[4])
|
||||
}
|
||||
return attrs.src ? attrs : null
|
||||
}
|
||||
|
||||
const ATTR_ENTITIES: Record<string, string> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
||||
|
||||
/** 解码 HTML 属性值里的常见实体(mdast 保留的是源码原文,不解码)。单遍替换防二次解码。 */
|
||||
function decodeAttr(v: string): string {
|
||||
return v.replace(/&(?:quot|amp|lt|gt);|'/g, (m) => ATTR_ENTITIES[m] ?? m)
|
||||
}
|
||||
|
||||
interface MdastNode {
|
||||
type: string
|
||||
value?: string
|
||||
children?: MdastNode[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** remark 插件:把孤立 <img> 的 mdast html 节点换成带 data.width 的 image 节点。 */
|
||||
export function remarkImgSizePlugin() {
|
||||
const toImage = (child: MdastNode): MdastNode | null => {
|
||||
if (child?.type !== 'html') return null
|
||||
const attrs = parseImgTag(String(child.value ?? ''))
|
||||
if (!attrs) return null
|
||||
return {
|
||||
type: 'image',
|
||||
url: attrs.src,
|
||||
alt: attrs.alt ?? '',
|
||||
title: attrs.title ?? '',
|
||||
data: { width: attrs.width ?? null },
|
||||
} as MdastNode
|
||||
}
|
||||
const walk = (node: MdastNode): void => {
|
||||
if (!node || typeof node !== 'object' || !Array.isArray(node.children)) return
|
||||
node.children = node.children.map((child) => {
|
||||
const img = toImage(child)
|
||||
if (img) {
|
||||
// 根级是块上下文,image 是行内节点,必须包进段落;段落内原地替换
|
||||
return node.type === 'root' ? { type: 'paragraph', children: [img] } : img
|
||||
}
|
||||
walk(child)
|
||||
return child
|
||||
})
|
||||
}
|
||||
return (tree: MdastNode) => walk(tree)
|
||||
}
|
||||
|
||||
function escapeAttr(v: string): string {
|
||||
return v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
}
|
||||
|
||||
/** 在编辑器 config 阶段调用:注册 remark 插件 + 包裹图片 schema。 */
|
||||
export function extendImageSchema(ctx: Ctx): void {
|
||||
// remarkPluginsCtx 的条目类型是 unified 的 Plugin<[..], Root>,与本文件
|
||||
// 的宽松 mdast 类型对不上;运行时形状 { plugin, options } 已实测无误。
|
||||
const entry = { plugin: remarkImgSizePlugin, options: undefined } as unknown as
|
||||
ReturnType<typeof remarkPluginsCtx.create> extends (infer T)[] ? T : never
|
||||
ctx.update(remarkPluginsCtx, (prev) => [...prev, entry])
|
||||
ctx.update(imageSchema.key, (factory) => (c) => {
|
||||
const spec = factory(c)
|
||||
return {
|
||||
...spec,
|
||||
attrs: { ...spec.attrs, width: { default: null } },
|
||||
parseMarkdown: {
|
||||
match: (node: { type: string }) => node.type === 'image',
|
||||
runner: (state, node, type) => {
|
||||
const mdast = node as unknown as { url: string; alt?: string; title?: string; data?: { width?: string | null } }
|
||||
state.addNode(type, {
|
||||
src: mdast.url,
|
||||
alt: mdast.alt ?? '',
|
||||
title: mdast.title ?? '',
|
||||
width: mdast.data?.width ?? null,
|
||||
})
|
||||
},
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node: { type: { name: string } }) => node.type.name === 'image',
|
||||
runner: (state, node) => {
|
||||
const width = node.attrs.width as string | null
|
||||
if (width) {
|
||||
const title = node.attrs.title ? ` title="${escapeAttr(String(node.attrs.title))}"` : ''
|
||||
state.addNode(
|
||||
'html',
|
||||
undefined,
|
||||
`<img src="${escapeAttr(String(node.attrs.src))}" alt="${escapeAttr(String(node.attrs.alt))}"${title} width="${escapeAttr(String(width))}">`,
|
||||
)
|
||||
} else {
|
||||
state.addNode('image', undefined, undefined, {
|
||||
title: node.attrs.title,
|
||||
url: node.attrs.src,
|
||||
alt: node.attrs.alt,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** width 属性值 → CSS 宽度:纯数字按 px,带单位(%/px/em…)原样用。 */
|
||||
export function widthToCss(width: string | null | undefined): string | null {
|
||||
if (!width) return null
|
||||
const t = String(width).trim()
|
||||
if (!t) return null
|
||||
return /^\d+(\.\d+)?$/.test(t) ? `${t}px` : t
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
// 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。
|
||||
// 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。
|
||||
// 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 { $prose } from '@milkdown/kit/utils'
|
||||
import { commandsCtx } from '@milkdown/kit/core'
|
||||
@@ -416,14 +417,72 @@ export function createPlainTextPastePlugin() {
|
||||
// resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。
|
||||
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>) {
|
||||
return $prose(() => new Plugin({
|
||||
props: {
|
||||
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')
|
||||
img.alt = (node.attrs.alt as string) ?? ''
|
||||
img.draggable = false
|
||||
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 = ''
|
||||
const apply = (src: string) => {
|
||||
currentSrc = src
|
||||
@@ -437,11 +496,53 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise<strin
|
||||
.catch(() => undefined)
|
||||
}
|
||||
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 {
|
||||
dom: img,
|
||||
dom: wrap,
|
||||
update: (next) => {
|
||||
if (next.type.name !== 'image') return false
|
||||
currentNode = next
|
||||
img.alt = (next.attrs.alt as string) ?? ''
|
||||
if (!drag) applyWidth(next.attrs.width)
|
||||
const src = (next.attrs.src as string) ?? ''
|
||||
if (src !== currentSrc) apply(src)
|
||||
return true
|
||||
|
||||
@@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react'
|
||||
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
|
||||
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
|
||||
* 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
|
||||
* refs 可传多个:菜单经 Portal 渲染到 body 时(如下拉展开层),触发器与
|
||||
* 菜单是两个分离的 DOM 子树,两边都算「内」。
|
||||
*/
|
||||
export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean, onClose: () => void) {
|
||||
export function useDismiss(
|
||||
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
|
||||
active: boolean,
|
||||
onClose: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const outside = (target: EventTarget | null) =>
|
||||
ref.current && target instanceof Node && !ref.current.contains(target)
|
||||
const list = Array.isArray(refs) ? refs : [refs]
|
||||
const outside = (target: EventTarget | null) => {
|
||||
if (!(target instanceof Node)) return false
|
||||
const containers = list.map((r) => r.current).filter(Boolean) as HTMLElement[]
|
||||
return containers.length > 0 && containers.every((el) => !el.contains(target))
|
||||
}
|
||||
const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
const onScroll = (e: Event) => { if (outside(e.target)) onClose() }
|
||||
@@ -26,5 +36,5 @@ export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean,
|
||||
document.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('blur', onClose)
|
||||
}
|
||||
}, [ref, active, onClose])
|
||||
}, [refs, active, onClose])
|
||||
}
|
||||
|
||||
@@ -348,3 +348,30 @@ export async function gitSetRemote(root: string, remoteUrl: string): Promise<Git
|
||||
export async function gitSyncNow(root: string, message: string): Promise<GitSyncOutcome> {
|
||||
return invoke<GitSyncOutcome>('git_sync_now', { root, message })
|
||||
}
|
||||
|
||||
// ——— S3 图片上传(docs/30):配置随调用传入,Rust 侧签名 + PUT,不保存密钥 ———
|
||||
|
||||
export interface S3ConfigPayload {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
prefix: string
|
||||
publicBase: string
|
||||
}
|
||||
|
||||
/** 上传一个本地图片文件,返回应回填文档的公开访问地址。 */
|
||||
export async function s3UploadImage(config: S3ConfigPayload, filePath: string, fileName: string): Promise<string> {
|
||||
return invoke<string>('s3_upload_image', { config, filePath, fileName })
|
||||
}
|
||||
|
||||
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
|
||||
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
|
||||
return invoke<string>('s3_upload_base64', { config, base64, fileName })
|
||||
}
|
||||
|
||||
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
|
||||
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
|
||||
return invoke<string>('s3_test_connection', { config })
|
||||
}
|
||||
|
||||
121
src/services/s3.ts
Normal file
121
src/services/s3.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// S3 图片上传的前端侧(docs/30):设置形状、完整性判定、本地图片识别与
|
||||
// 路径拼接。真正的签名与 PUT 在 Rust(s3.rs),密钥只随调用传入、不落 Rust 盘。
|
||||
import type { S3ConfigPayload } from './platform'
|
||||
|
||||
export interface S3Settings extends S3ConfigPayload {
|
||||
/** 粘贴/插入图片落盘后自动上传并把引用换成 S3 地址 */
|
||||
autoUpload: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_S3_SETTINGS: S3Settings = {
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
prefix: '',
|
||||
publicBase: '',
|
||||
autoUpload: false,
|
||||
}
|
||||
|
||||
/** 四项必填齐了才算配置过(endpoint/前缀/访问域名都是可选)。 */
|
||||
export function isS3Configured(s: S3Settings): boolean {
|
||||
return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim())
|
||||
}
|
||||
|
||||
/** 本地图片 = 文档里的相对/本地文件引用;http(s)/blob 已经是网络资源,不需要上传。 */
|
||||
export function isLocalImageSrc(src: string): boolean {
|
||||
const t = src.trim()
|
||||
if (!t) return false
|
||||
return !/^(https?:|data:|blob:)/i.test(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内嵌 base64 图片(data:image/...;base64,...):未落盘文档粘贴的图片、
|
||||
* 导入文档自带的图片都是这种形态——同样算「未上传」,上传时直接传字节,
|
||||
* 不需要本地文件(2026-11-15 用户指正:原先把 data: 一律排除在检测外)。
|
||||
*/
|
||||
export function parseDataImage(src: string): { mime: string; base64: string } | null {
|
||||
const m = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(src.trim())
|
||||
if (!m) return null
|
||||
return { mime: m[1].toLowerCase(), base64: m[2].replace(/\s+/g, '') }
|
||||
}
|
||||
|
||||
export function isDataImageSrc(src: string): boolean {
|
||||
return parseDataImage(src) !== null
|
||||
}
|
||||
|
||||
/** 需要上传的图片 = 本地文件引用 或 内嵌 base64 图片。 */
|
||||
export function isUploadableImageSrc(src: string): boolean {
|
||||
return isLocalImageSrc(src) || isDataImageSrc(src)
|
||||
}
|
||||
|
||||
/**
|
||||
* base64 图片的对象文件名:image-<内容哈希>.<扩展名>。
|
||||
* 用内容哈希(FNV-1a)命名:同一张图重复上传同名覆盖、幂等;不同图不撞名。
|
||||
*/
|
||||
export function dataImageFileName(src: string): string | null {
|
||||
const parsed = parseDataImage(src)
|
||||
if (!parsed) return null
|
||||
let ext = parsed.mime.split('/')[1] ?? 'png'
|
||||
ext = ext.split('+')[0]
|
||||
if (ext === 'jpeg') ext = 'jpg'
|
||||
if (!/^[a-z0-9]+$/.test(ext)) ext = 'png'
|
||||
let h = 0x811c9dc5
|
||||
const b64 = parsed.base64
|
||||
for (let i = 0; i < b64.length; i++) {
|
||||
h ^= b64.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193)
|
||||
}
|
||||
return `image-${(h >>> 0).toString(16).padStart(8, '0')}.${ext}`
|
||||
}
|
||||
|
||||
/** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */
|
||||
export function s3ConfigPayload(s: S3Settings): S3ConfigPayload {
|
||||
return {
|
||||
endpoint: s.endpoint,
|
||||
region: s.region,
|
||||
bucket: s.bucket,
|
||||
accessKey: s.accessKey,
|
||||
secretKey: s.secretKey,
|
||||
prefix: s.prefix,
|
||||
publicBase: s.publicBase,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把文档相对路径的图片 src 拼成绝对文件路径(供 Rust 读文件)。
|
||||
* 文本级归一:统一 / 分隔、处理 . 与 ..(图片一般在 <文档名>.assets/ 下,
|
||||
* 手写文档也可能用 ../ 引用)。Windows 盘符与正斜杠都兼容。
|
||||
*/
|
||||
export function resolveDocRelativePath(docPath: string, relSrc: string): string {
|
||||
const decoded = safeDecode(relSrc).replace(/\\/g, '/')
|
||||
if (/^[a-zA-Z]:\//.test(decoded) || decoded.startsWith('/')) return decoded
|
||||
const dir = docPath.replace(/\\/g, '/').replace(/\/[^/]*$/, '')
|
||||
const parts: string[] = []
|
||||
for (const seg of `${dir}/${decoded}`.split('/')) {
|
||||
if (seg === '' || seg === '.') {
|
||||
if (seg === '' && parts.length === 0) parts.push('')
|
||||
continue
|
||||
}
|
||||
if (seg === '..') { parts.pop(); continue }
|
||||
parts.push(seg)
|
||||
}
|
||||
return parts.join('/') || '/'
|
||||
}
|
||||
|
||||
function safeDecode(s: string): string {
|
||||
try { return decodeURIComponent(s) } catch { return s }
|
||||
}
|
||||
|
||||
/** 进度/报错里展示的图片名:base64 图用其对象名(哈希名),文件图用原文件名。 */
|
||||
export function imageDisplayName(src: string): string {
|
||||
return dataImageFileName(src) ?? imageFileName(src)
|
||||
}
|
||||
|
||||
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
|
||||
export function imageFileName(src: string): string {
|
||||
const clean = safeDecode(src).replace(/\\/g, '/')
|
||||
const name = clean.split('/').pop() ?? ''
|
||||
return name || 'image.png'
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export type TabsAction =
|
||||
| { type: 'open'; tab: DocTab }
|
||||
| { type: 'activate'; id: string }
|
||||
| { type: 'updateContent'; id: string; content: string }
|
||||
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string }
|
||||
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string; encoding?: string }
|
||||
| { type: 'syncEditorContent'; id: string; content: string }
|
||||
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
|
||||
| { type: 'setPath'; id: string; path: string; title: string }
|
||||
@@ -93,7 +93,7 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === action.id
|
||||
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title }
|
||||
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title, encoding: action.encoding ?? t.encoding }
|
||||
: t,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@ button { font: inherit; color: inherit; }
|
||||
.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: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:hover { color: var(--text); border-color: var(--accent); }
|
||||
.tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||
@@ -159,6 +161,11 @@ 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.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-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; }
|
||||
.shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
.shortcuts-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); }
|
||||
@@ -177,6 +184,11 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
||||
.milkdown .ProseMirror table { border-collapse: collapse; }
|
||||
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
|
||||
.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 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; }
|
||||
@@ -358,7 +370,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
||||
.dropdown-button:disabled { opacity: 0.5; cursor: default; }
|
||||
.dropdown-button svg { flex: none; color: var(--text-dim); }
|
||||
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dropdown-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; max-height: 264px; overflow-y: auto; }
|
||||
/* 展开层几何(fixed 定位/宽/最大高)由 Dropdown 组件内联给出:Portal 到 body 逃出弹窗等 overflow 容器的裁剪 */
|
||||
.dropdown-menu { background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; }
|
||||
.dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
|
||||
.dropdown-menu button.selected { background: var(--accent-weak); }
|
||||
|
||||
@@ -22,18 +22,44 @@ describe('Dropdown', () => {
|
||||
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||
expect(button.textContent).toContain('GitHub')
|
||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
const menu = container.querySelector('.dropdown-menu')
|
||||
const menu = document.querySelector('.dropdown-menu')
|
||||
expect(menu).not.toBeNull()
|
||||
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
|
||||
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
|
||||
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(onChange).toHaveBeenCalledWith('notion')
|
||||
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
||||
expect(document.querySelector('.dropdown-menu')).toBeNull()
|
||||
// 再次展开,点别处关闭
|
||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(container.querySelector('.dropdown-menu')).not.toBeNull()
|
||||
expect(document.querySelector('.dropdown-menu')).not.toBeNull()
|
||||
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
expect(document.querySelector('.dropdown-menu')).toBeNull()
|
||||
await act(async () => { root.unmount() })
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('展开层经 Portal 挂到 body;触发钮靠近窗口底部时向上翻且不溢出', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(<Dropdown value="github" options={OPTIONS} onChange={() => undefined} />)
|
||||
})
|
||||
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||
// 模拟触发钮贴近窗口底部(jsdom 默认视口高 768):下方只剩 30px
|
||||
button.getBoundingClientRect = () => ({
|
||||
top: 700, bottom: 730, left: 100, right: 300, width: 200, height: 30, x: 100, y: 700, toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
// Portal:菜单不在组件容器内,而是 body 直属
|
||||
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
||||
const menu = document.querySelector('.dropdown-menu') as HTMLElement | null
|
||||
expect(menu).not.toBeNull()
|
||||
expect(menu!.style.position).toBe('fixed')
|
||||
// 3 项估算高 101,下方放不下 → 翻到触发钮上方:top = 700 - 101 - 4
|
||||
expect(menu!.style.top).toBe('595px')
|
||||
expect(menu!.style.left).toBe('100px')
|
||||
expect(menu!.style.width).toBe('200px')
|
||||
await act(async () => { root.unmount() })
|
||||
container.remove()
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ async function renderMenu(props: Record<string, unknown> = {}) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditorContextMenu
|
||||
x={10} y={10} hasSelection={false} sourceMode={false}
|
||||
x={10} y={10} hasSelection={false} imageSrc={null} sourceMode={false}
|
||||
onAction={(a) => actions.push(a)}
|
||||
onClose={() => { closed += 1 }}
|
||||
{...props}
|
||||
@@ -38,6 +38,20 @@ describe('编辑区右键菜单', () => {
|
||||
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 } = await renderMenu({ sourceMode: true, hasSelection: true })
|
||||
expect(btn(container, '加粗')).toBeUndefined()
|
||||
|
||||
53
tests/editor-images.test.ts
Normal file
53
tests/editor-images.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 编辑器图片节点操作(docs/30):收集 src、按旧 src 回换、按 DOM 元素反查 src。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc, imageSrcAtElement } from '../src/editor/editor'
|
||||
|
||||
const DOC = [
|
||||
'# 图',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
async function makeEditor() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const editor = createMarkdownEditor(root, DOC)
|
||||
await editor.create()
|
||||
return { root, editor }
|
||||
}
|
||||
|
||||
describe('editor image ops', () => {
|
||||
it('收集全部图片 src 并去重', async () => {
|
||||
const { root, editor } = await makeEditor()
|
||||
expect(collectImageSrcs(editor)).toEqual(['doc.assets/a.png', 'https://cdn.example.com/b.png'])
|
||||
await editor.destroy()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('按旧 src 回换全部同名图片节点(含重复引用)', async () => {
|
||||
const { root, editor } = await makeEditor()
|
||||
const n = replaceImageSrc(editor, 'doc.assets/a.png', 'https://s3.example.com/a.png')
|
||||
expect(n).toBe(2)
|
||||
const md = getMarkdown(editor)
|
||||
expect(md).toContain('https://s3.example.com/a.png')
|
||||
expect(md).not.toContain('doc.assets/a.png')
|
||||
expect(collectImageSrcs(editor)).toEqual(['https://s3.example.com/a.png', 'https://cdn.example.com/b.png'])
|
||||
await editor.destroy()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('按 <img> 元素反查节点 src;非图片元素返回 null', async () => {
|
||||
const { root, editor } = await makeEditor()
|
||||
const img = root.querySelector('img')
|
||||
expect(img).not.toBeNull()
|
||||
expect(imageSrcAtElement(editor, img as HTMLElement)).toBe('doc.assets/a.png')
|
||||
expect(imageSrcAtElement(editor, root)).toBeNull()
|
||||
await editor.destroy()
|
||||
root.remove()
|
||||
})
|
||||
})
|
||||
27
tests/encoding-modal.test.tsx
Normal file
27
tests/encoding-modal.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
// docs/29 D1:非 UTF-8 文档保存转码提示弹窗的渲染与回调契约。
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { EncodingConvertModal } from '../src/components/Modals'
|
||||
|
||||
describe('EncodingConvertModal', () => {
|
||||
it('显示原编码与文件名,确认/取消回调正确', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
await act(async () => {
|
||||
root.render(<EncodingConvertModal encoding="GBK" fileName="老文档.md" onConfirm={onConfirm} onCancel={onCancel} />)
|
||||
})
|
||||
expect(container.textContent).toContain('GBK')
|
||||
expect(container.textContent).toContain('老文档.md')
|
||||
expect(container.textContent).toContain('转为 UTF-8')
|
||||
const buttons = [...container.querySelectorAll('button')]
|
||||
buttons.find((b) => b.textContent?.includes('转为 UTF-8'))?.click()
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
buttons.find((b) => b.textContent?.includes('取消'))?.click()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { root.unmount() })
|
||||
})
|
||||
})
|
||||
44
tests/image-enter.test.ts
Normal file
44
tests/image-enter.test.ts
Normal 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\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('')
|
||||
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()
|
||||
})
|
||||
})
|
||||
108
tests/image-size.test.ts
Normal file
108
tests/image-size.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { createMarkdownEditor, getMarkdown, imageInfoAtElement, updateImageAtElement } from '../src/editor/editor'
|
||||
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('前文  后文\n')
|
||||
expect(imageAttrs(editor)).toEqual([{ src: 'a.png', alt: '图', title: '', width: null }])
|
||||
expect(getMarkdown(editor)).toBe('前文  后文\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('\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('\n')
|
||||
await editor.destroy(); root.remove()
|
||||
})
|
||||
|
||||
it('alt 里的引号在 HTML 序列化时转义、解析时还原', async () => {
|
||||
const { root, editor } = await makeEditor('<img src="a.png" alt="a"b" width="10">\n')
|
||||
expect(imageAttrs(editor)[0].alt).toBe('a"b')
|
||||
expect(getMarkdown(editor)).toBe('<img src="a.png" alt="a"b" width="10">\n')
|
||||
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:设置包裹链接 → [](href),清空则还原', async () => {
|
||||
const { root, editor } = await makeEditor('\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('[](https://example.com)\n')
|
||||
expect(updateImageAtElement(editor, img2, { linkHref: null })).toBe(true)
|
||||
expect(getMarkdown(editor)).toBe('\n')
|
||||
await editor.destroy(); root.remove()
|
||||
})
|
||||
|
||||
it('已有包裹链接的文档解析后能读出 href', async () => {
|
||||
const { root, editor } = await makeEditor('[](https://x.dev)\n')
|
||||
const img = root.querySelector('img')!
|
||||
expect(imageInfoAtElement(editor, img)?.linkHref).toBe('https://x.dev')
|
||||
await editor.destroy(); root.remove()
|
||||
})
|
||||
})
|
||||
49
tests/s3-modal.test.tsx
Normal file
49
tests/s3-modal.test.tsx
Normal 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
64
tests/s3.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user