Compare commits

...

10 Commits

Author SHA1 Message Date
Muse
fb51276bc5 feat(s3): base64 内嵌图片纳入未上传检测与上传(用户指正)
初版把 data: 一律排除在「未上传」外——未落盘文档粘贴的图、导入
文档自带的图都是 data:image/...;base64 形态,批量/右键/状态栏全
检测不到。现 isUploadableImageSrc = 本地文件引用 ∪ base64 内嵌图:
base64 图走新命令 s3_upload_base64(总数 31 对账),前端剥前缀传
纯 base64、Rust 解码后与文件图共用同一条 PUT 通路;对象名按内容
哈希(FNV-1a)生成,同图同名幂等。未落盘文档粘贴图也挂上自动上传。
进度/报错展示名对 base64 图用其哈希名,不再漏出 base64 串。
验证:独立 crate 对模拟 S3 实测 base64 解码 PUT;vitest 136+2、构建通过。
2026-11-15 09:16:18 +08:00
Muse
b3c22fd597 fix(s3): 自定义访问域名回填地址补上存储桶一段(域名/桶/前缀/文件名)
MinIO 等自建服务 API 地址与访问地址分离,访问域名指向服务根,
原实现按 域名/前缀/文件名 拼、漏了桶名,文档里的地址会 404。
改为 域名/bucket/key;设置弹窗说明同步写清拼接规则,docs/30 补记。
同份 s3.rs 在独立 crate 对模拟 S3 复测通过(期望值已更新)。
2026-11-15 09:03:25 +08:00
Muse
967aada3ca feat(s3): 图片上传到 S3——设置/自动上传/右键单张/批量上传(docs/30)
Rust 新增 s3.rs:SigV4 签名 + PUT Object(reqwest blocking+rustls,
签名对照 AWS 官方示例向量逐字一致;同份代码对本地模拟 S3 端到端
实测通过)。命令 s3_upload_image / s3_test_connection(总数 30 对账)。
配置存 UiState.s3(本机偏好),设置弹窗含测试连接与自动上传开关。
未上传判定 = src 非 http/data/blob 的本地引用;上传成功后同 src 的
图片节点一次事务回换为 S3 地址,本地 .assets 文件保留。
入口:粘贴/插入后自动上传回换(扑空重试一次、失败必提示);右键
命中本地图片多一项「上传到 S3」;文件菜单 + 状态栏批量上传带进度
弹窗。验证:vitest 133+2、tsc、构建通过,设置弹窗截图核对。
2026-11-15 08:38:47 +08:00
03c6c15439 chore(bundle): MSI 安装器切换为简体中文(zh-CN) 2026-11-15 08:20:45 +08:00
8194345667 chore(bundle): NSIS 安装器切换为简体中文界面 2026-11-14 21:22:12 +08:00
499cec2273 chore(git): 忽略 IDE 配置与 vite 时间戳临时文件 2026-11-14 20:36:28 +08:00
Muse
cd64110bf8 chore(git-sync): 仓库地址输入框提示改为 github 示例地址 2026-11-14 20:29:02 +08:00
Muse
eb27e4ca2d fix(dropdown): 展开层改 Portal + fixed 定位,逃出弹窗 overflow 裁剪
弹窗(.modal 带 overflow-y:auto)里的下拉展开层是 absolute 定位,
会被弹窗滚动盒裁掉下半截(Git 同步设置里实测被遮挡)。展开层改经
Portal 挂到 body、按触发钮 rect 用 fixed 定位:下方空间不足时
向上翻,maxHeight 按可用空间夹取;窗口 resize 时关闭。
useDismiss 泛化为可传多个 ref(触发器与 Portal 菜单两棵子树都算内)。
CSS 相应去掉 .dropdown-menu 的 absolute 定位(几何改由内联给出)。
验证:vitest 124+2(新增翻转几何断言:贴底触发钮翻上展开、
Portal 落点不在组件容器内);无头 Chromium 实测弹窗内下拉菜单
完整落在视口内、8 项全显、已逃出弹窗 DOM。
2026-11-14 20:17:51 +08:00
Muse
4fca4b7143 fix(rust): spawn_blocking 改走 tauri::async_runtime(工程无 tokio 依赖,Windows 构建 E0433)
6 处 tokio::task::spawn_blocking(Git 同步 4 处 + docs/29 资源
命令 2 处)全部换成工程既有写法 tauri::async_runtime::spawn_blocking;
全量核对源码外部 crate 前缀与 Cargo.toml 依赖一致。docs/29 补勘误。
2026-11-14 18:09:30 +08:00
Muse
c602573d8e feat(save): 非 UTF-8 文档保存前转码提示(docs/29 D1,用户拍板)
GBK/UTF-16 文档保存会落盘为 UTF-8,原先无任何提示。saveTab 在
落盘前拦截:首次保存弹 EncodingConvertModal 说明将转 UTF-8,
按路径记已确认、本次运行只提示一次;自动保存不弹窗也不静默
转码,跳过等手动保存时提示;确认后以冻结快照 finishSave 收尾,
标签 encoding 如实更新为 UTF-8。弹窗渲染移到弹窗栈最后,避免
被关窗确认框盖住。vitest 123+2、构建通过。
2026-11-14 18:06:02 +08:00
29 changed files with 1814 additions and 73 deletions

2
.gitignore vendored
View File

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

View File

@@ -132,6 +132,13 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据
- D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16 - D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16
文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但 文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但
落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。 落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。
→ **已于当晚按用户拍板实现**:saveTab 在落盘前拦截,首次保存
弹 EncodingConvertModal 说明将转 UTF-8(按路径记已确认、本次
运行只提示一次);自动保存不弹窗也不静默转码,跳过该文档等
手动保存时提示;确认后以冻结快照完成落盘,标签 encoding 如实
更新为 UTF-8。弹窗渲染置于所有弹窗之后(避免被关窗确认框
盖住)。vitest 123+2(含弹窗契约测试)。真机验证点:拿一个
GBK 文档改字后 Ctrl+S,应先弹提示、确认后才落盘。
- D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与 - D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与
顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。 顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。
- D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms - D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms
@@ -183,3 +190,11 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据
- H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用 - H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用
「打开 >60K 文档后立刻连开多个标签挤掉其窗格,再回看该文档 「打开 >60K 文档后立刻连开多个标签挤掉其窗格,再回看该文档
是否完整」一验。 是否完整」一验。
- **勘误(2026-11-14 晚,Windows 构建回报)**:本轮 M1 与 docs/27
的 Git 命令共 6 处用了 `tokio::task::spawn_blocking`,但工程并
没有 tokio 直接依赖(Cargo.toml 只有 tauri 等 7 个),Windows
构建报 E0433。已全部改为工程既有写法的
`tauri::async_runtime::spawn_blocking`(read/write 命令一直在
用),并全量核对源码中所有外部 crate 前缀与 Cargo.toml 依赖
一致、无其他幻影 crate。教训:写 Rust 时新引入的 crate 路径
必须先对 Cargo.toml 存量,沙箱无法完整编译时尤其如此。

78
docs/30-S3图片上传.md Normal file
View File

@@ -0,0 +1,78 @@
# docs/30 — S3 图片上传
需求(2026-11-15 用户提出):①设置里能配 S3 并有「自动上传图片」开关;
②未上传的图片右键要有「上传到 S3」;③整个文档要有批量检测未上传图片
并上传的按钮。
## 设计
- **协议层在 Rust**(`src-tauri/src/s3.rs`):AWS SigV4 签名 + PUT Object,
reqwest(blocking + rustls)。配置随每次调用从前端传入,Rust 不落盘
保存密钥。命令 2 个(总数 30 与 lib.rs 注册对账):`s3_upload_image`、
`s3_test_connection`(对桶发签名 HEAD,按 200/403/404 给可读结论)。
- **寻址风格**:Endpoint 留空 = AWS S3 虚拟主机风格
`https://{bucket}.s3.{region}.amazonaws.com/{key}`;填了自定义
Endpoint(MinIO / R2 / OSS 等 S3 兼容服务)走路径风格
`{endpoint}/{bucket}/{key}`。对象键 = 前缀 + 原文件名(同名覆盖,
重复上传幂等)。配了「自定义访问域名」时,回填文档的地址按
「域名/存储桶/前缀/文件名」拼——MinIO 等自建服务里 API 地址与对外
访问地址是两个域名、访问域名通常指向服务根,桶名必须出现在路径里
(2026-11-15 用户指正,原实现漏了桶名一段)。不填访问域名时回填的
就是上传请求本身的地址(路径风格已含桶名、AWS 虚拟主机风格桶名在
域名里)。
- **配置存储**:进 UiState.s3(本机偏好库 SQLite/localStorage),字段
endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload;
加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。
弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。
- **「未上传」判定**:本地文件引用(相对路径)或内嵌 base64 图片
(`data:image/...;base64,...`)都算未上传(`isUploadableImageSrc`);
http(s)/blob 不算。base64 形态来自未落盘文档的粘贴图与导入文档自带
图(2026-11-15 用户指正:初版把 data: 一律排除、检测不到)。base64
图没有文件可读,走单独的 `s3_upload_base64` 命令(命令总数 31):
前端剥前缀传纯 base64,Rust 解码后与文件图共用同一条 PUT 通路;
对象名按内容哈希生成(image-<FNV-1a>.<扩展名>),同图同名幂等。
上传成功后把编辑器里所有同 src 的图片节点
一次事务换成 S3 地址(`replaceImageSrc`,同图多处引用一并换),文档
随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉
离线打开时的图,且用户可能还要本地备份)。
## 三个入口
1. **自动上传**(开关在 S3 设置里):粘贴/拖入(handleImageFile 落盘后)
与「插入图片」(copyToAssets 后)触发后台上传+回换;粘贴建节点是
异步的,第一次回换扑空时 900ms 后重试一次;再扑空或上传失败都给
提示(flash),不静默、本地引用保留。
2. **右键单张**:编辑区右键命中 <img> 时经 `imageSrcForElement`
(posAtDOM 反查节点)拿到文档 src;本地图时菜单多一项「上传到 S3」。
未配置 S3 时点它先弹设置弹窗并提示。
3. **批量**:文件 →「上传文档图片到 S3…」+ 状态栏按钮(当前文档有未
上传本地图片时出现,带张数)。扫描走编辑器 `listImages()`(PM 文档
遍历、去重),逐张上传回换,进度弹窗实时显示 第 N/总 张与当前文件
名,结束汇总成功/失败明细。源码模式下不可用(菜单禁用、状态栏不
计数),提示切回即时渲染。
## 验证
- **签名算法**:独立小 crate 里对照 AWS 官方文档示例(IAM ListUsers,
20150830T123600Z)——算出的 Signature 与官方值
`5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7`
逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。
- **base64 通路**:同一独立 crate 里实测 upload_base64:解码后的字节
与原文一致地 PUT 到模拟 S3、Content-Type 按扩展名、非法 base64 在
发请求前就报错。
- **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地
TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、
x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、
自定义访问域名回填、HEAD 桶测试连接、空配置校验,2 例全过。
- **前端**:vitest 133+2(新增 s3.test 路径/判定、editor-images 收集/
回换/元素反查、s3-modal 保存载荷、右键菜单图片项);tsc 与构建通过;
设置弹窗无头浏览器截图核对(七个字段、开关、三按钮齐全)。
- **Cargo.lock 已更新**(reqwest 0.12.28 等,本机 cargo fetch 完成)。
## 待真机验证(Windows)
- Rust 完整构建(新增 reqwest/sha2/hmac 依赖)与真实 S3/兼容服务联调:
填真实配置 → 测试连接 → 粘贴图片自动上传 → 右键单张 → 批量上传,
并在桶里核对对象与文档回填地址可公开访问(桶需允许公开读或配 CDN)。
- 注意:签名时钟用系统 UTC 时间,本机时钟偏差过大会被 S3 拒绝
(报错会带回 S3 的 Code/Message,按提示校时即可)。

404
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,3 +19,7 @@ serde_json = "1"
encoding_rs = "0.8" encoding_rs = "0.8"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
trash = "5" trash = "5"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
sha2 = "0.10"
hmac = "0.12"
base64 = "0.22"

View File

@@ -258,7 +258,7 @@ fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, S
/// 大文件会冻 UI 线程且内存整载(docs/29)。 /// 大文件会冻 UI 线程且内存整载(docs/29)。
#[tauri::command] #[tauri::command]
pub async fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> { pub async fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
tokio::task::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let src = PathBuf::from(&source); let src = PathBuf::from(&source);
let name = sanitize_file_name( let name = sanitize_file_name(
&src.file_name() &src.file_name()
@@ -287,7 +287,7 @@ pub async fn save_bytes_to_assets(
file_name: String, file_name: String,
base64: String, base64: String,
) -> Result<String, String> { ) -> Result<String, String> {
tokio::task::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let name = sanitize_file_name(&file_name); let name = sanitize_file_name(&file_name);
let bytes = base64_decode(&base64)?; let bytes = base64_decode(&base64)?;
if bytes.len() as u64 > MAX_ASSET_BYTES { if bytes.len() as u64 > MAX_ASSET_BYTES {
@@ -784,7 +784,7 @@ fn git_status_inner(root: &str) -> GitStatusInfo {
#[tauri::command] #[tauri::command]
pub async fn git_status(root: String) -> GitStatusInfo { 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 .await
.unwrap_or(GitStatusInfo { .unwrap_or(GitStatusInfo {
is_repo: false, is_repo: false,
@@ -799,7 +799,7 @@ pub async fn git_status(root: String) -> GitStatusInfo {
#[tauri::command] #[tauri::command]
pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result<GitStatusInfo, String> { 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 dir = Path::new(&root);
let b = validate_branch(&branch)?; let b = validate_branch(&branch)?;
let url = validate_remote_url(&remote_url)?; 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] #[tauri::command]
pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatusInfo, String> { 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 dir = Path::new(&root);
let url_owned = validate_remote_url(&remote_url)?; let url_owned = validate_remote_url(&remote_url)?;
let url = url_owned.as_str(); 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] #[tauri::command]
pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcome, String> { 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 dir = Path::new(&root);
let st = git_status_inner(&root); let st = git_status_inner(&root);
if !st.is_repo { if !st.is_repo {
@@ -925,3 +925,36 @@ pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcom
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} }
// ————————————————— S3 图片上传(docs/30) —————————————————
// 配置随调用从前端传入(设置弹窗收集、存本机偏好),Rust 侧不保存密钥;
// 实现在 s3.rs(SigV4 签名已对照 AWS 官方示例向量验证)。
#[tauri::command]
pub async fn s3_upload_image(
config: crate::s3::S3Config,
file_path: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || crate::s3::upload(&config, &file_path, &file_name))
.await
.map_err(|e| format!("上传任务失败:{e}"))?
}
#[tauri::command]
pub async fn s3_upload_base64(
config: crate::s3::S3Config,
base64: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || crate::s3::upload_base64(&config, &base64, &file_name))
.await
.map_err(|e| format!("上传任务失败:{e}"))?
}
#[tauri::command]
pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))
.await
.map_err(|e| format!("连接测试失败:{e}"))?
}

View File

@@ -1,4 +1,5 @@
mod commands; mod commands;
mod s3;
mod search; mod search;
mod storage; mod storage;
@@ -66,6 +67,9 @@ pub fn run() {
commands::git_init_repo, commands::git_init_repo,
commands::git_set_remote, commands::git_set_remote,
commands::git_sync_now, commands::git_sync_now,
commands::s3_upload_image,
commands::s3_upload_base64,
commands::s3_test_connection,
commands::delete_to_trash, commands::delete_to_trash,
commands::db_recent_list, commands::db_recent_list,
commands::db_recent_add, commands::db_recent_add,

348
src-tauri/src/s3.rs Normal file
View File

@@ -0,0 +1,348 @@
//! S3 兼容对象存储上传(docs/30):AWS SigV4 签名 + PUT Object。
//! 配置由前端设置弹窗收集、随每次调用传入,Rust 侧不落盘保存密钥。
//! endpoint 为空 = AWS S3(虚拟主机风格);填了自定义 endpoint(MinIO /
//! R2 / OSS 等 S3 兼容服务)走路径风格 {endpoint}/{bucket}/{key}。
//! 签名实现已对照 AWS 官方文档示例向量验证(签名值逐字一致)。
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct S3Config {
/// 自定义服务端点(空 = AWS 官方),如 https://minio.example.com:9000
#[serde(default)]
pub endpoint: String,
pub region: String,
pub bucket: String,
pub access_key: String,
pub secret_key: String,
/// 对象键前缀(可空),如 notes/
#[serde(default)]
pub prefix: String,
/// 自定义访问域名(可空),如 https://files.example.com;填了则回填文档的
/// 地址按「域名/存储桶/对象键」拼(API 地址与访问地址分离的部署,如
/// MinIO,访问域名指向服务根、桶名在路径里);不填则用上传请求本身的地址
#[serde(default)]
pub public_base: String,
}
const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
fn validate(cfg: &S3Config) -> Result<(), String> {
if cfg.bucket.trim().is_empty() {
return Err("请先在 S3 设置里填写存储桶(Bucket)。".into());
}
if cfg.region.trim().is_empty() {
return Err("请先在 S3 设置里填写区域(Region)。".into());
}
if cfg.access_key.trim().is_empty() || cfg.secret_key.trim().is_empty() {
return Err("请先在 S3 设置里填写 Access Key 与 Secret Key。".into());
}
let ep = cfg.endpoint.trim();
if !ep.is_empty() && !ep.starts_with("http://") && !ep.starts_with("https://") {
return Err("服务端点需以 http:// 或 https:// 开头。".into());
}
Ok(())
}
/// 对象键:前缀(归一为无前导 /、非空时以 / 结尾)+ 文件名。
fn object_key(cfg: &S3Config, file_name: &str) -> String {
let prefix = cfg.prefix.trim().trim_start_matches('/');
if prefix.is_empty() {
file_name.to_string()
} else if prefix.ends_with('/') {
format!("{prefix}{file_name}")
} else {
format!("{prefix}/{file_name}")
}
}
/// 上传目标:返回 (请求 URL, 签名用 host, canonical URI)。
fn target(cfg: &S3Config, key: &str) -> (String, String, String) {
let encoded_key = encode_uri_path(&format!("/{}", key.trim_start_matches('/')));
let endpoint = cfg.endpoint.trim().trim_end_matches('/');
if endpoint.is_empty() {
let host = format!("{}.s3.{}.amazonaws.com", cfg.bucket.trim(), cfg.region.trim());
(format!("https://{host}{encoded_key}"), host, encoded_key)
} else {
let after_scheme = endpoint.split("://").nth(1).unwrap_or(endpoint);
let host = after_scheme.split('/').next().unwrap_or(after_scheme).to_string();
let uri = format!("/{bucket}{encoded_key}", bucket = cfg.bucket.trim());
(format!("{endpoint}{uri}"), host, uri)
}
}
fn content_type_of(file_name: &str) -> &'static str {
let ext = file_name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
fn client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| format!("初始化网络客户端失败:{e}"))
}
/// 从 S3 的 XML 错误体里粗提取 Code / Message,给用户一句能看懂的话。
fn s3_error(status: reqwest::StatusCode, body: &str) -> String {
let grab = |tag: &str| -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = body.find(&open)? + open.len();
let end = body[start..].find(&close)? + start;
Some(body[start..end].trim().to_string())
};
match (grab("Code"), grab("Message")) {
(Some(code), Some(msg)) => format!("S3 返回 {status}({code}):{msg}"),
_ => format!("S3 返回 {status}"),
}
}
fn now_amz_dates() -> (String, String) {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
amz_dates(secs)
}
fn signed_request(
cfg: &S3Config,
method: &str,
host: &str,
canonical_uri: &str,
content_type: Option<&str>,
payload_hash: &str,
) -> (String, String, String) {
let (amz_date, date_stamp) = now_amz_dates();
let mut extras: Vec<(&str, &str)> = vec![("x-amz-content-sha256", payload_hash)];
if let Some(ct) = content_type {
extras.push(("content-type", ct));
}
let auth = authorization_header(&SignParams {
method,
host,
canonical_uri,
canonical_query: "",
region: cfg.region.trim(),
service: "s3",
access_key: cfg.access_key.trim(),
secret_key: cfg.secret_key.trim(),
amz_date: &amz_date,
date_stamp: &date_stamp,
payload_hash,
extra_headers: &extras,
});
(auth, amz_date, date_stamp)
}
/// 上传一个本地文件到 S3,成功返回文档应回填的公开访问地址。
pub fn upload(cfg: &S3Config, file_path: &str, file_name: &str) -> Result<String, String> {
validate(cfg)?;
let meta = std::fs::metadata(file_path)
.map_err(|e| format!("读取待上传图片失败({file_path}):{e}"))?;
if meta.len() > MAX_IMAGE_BYTES {
return Err("图片超过 64MB,未上传。".into());
}
let bytes = std::fs::read(file_path).map_err(|e| format!("读取待上传图片失败:{e}"))?;
upload_bytes(cfg, &bytes, file_name)
}
/// 上传文档内嵌的 base64 图片(data:image/...;base64 数据,前端已剥掉前缀)。
/// 未落盘文档粘贴的图片只有这种形态,没有文件可读。
pub fn upload_base64(cfg: &S3Config, base64_str: &str, file_name: &str) -> Result<String, String> {
validate(cfg)?;
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(base64_str.trim())
.map_err(|e| format!("图片 base64 解码失败:{e}"))?;
if bytes.len() as u64 > MAX_IMAGE_BYTES {
return Err("图片超过 64MB,未上传。".into());
}
upload_bytes(cfg, &bytes, file_name)
}
fn upload_bytes(cfg: &S3Config, bytes: &[u8], file_name: &str) -> Result<String, String> {
let bytes = bytes.to_vec();
let key = object_key(cfg, file_name);
let (url, host, canonical_uri) = target(cfg, &key);
let payload_hash = sha256_hex(&bytes);
let content_type = content_type_of(file_name);
let (auth, amz_date, _) = signed_request(cfg, "PUT", &host, &canonical_uri, Some(content_type), &payload_hash);
let resp = client()?
.put(&url)
.header("Authorization", auth)
.header("x-amz-date", amz_date)
.header("x-amz-content-sha256", &payload_hash)
.header("Content-Type", content_type)
.body(bytes)
.send()
.map_err(|e| format!("连接 S3 失败:{e}"))?;
let status = resp.status();
if status.is_success() {
let base = cfg.public_base.trim().trim_end_matches('/');
if base.is_empty() {
Ok(url)
} else {
// 自定义访问域名按「域名/存储桶/前缀/文件名」拼(docs/30):
// MinIO 等自建服务的 API 地址与对外访问地址是两个域名,访问域名
// 通常指向服务根,桶名必须出现在路径里,不能沿用虚拟主机风格的省略。
Ok(format!(
"{base}{}",
encode_uri_path(&format!("/{bucket}/{key}", bucket = cfg.bucket.trim()))
))
}
} else {
let body = resp.text().unwrap_or_default();
Err(s3_error(status, &body))
}
}
/// 测试连接:对存储桶发一个签名的 HEAD 请求,按状态码给出可读结论。
pub fn test_connection(cfg: &S3Config) -> Result<String, String> {
validate(cfg)?;
let (url, host, canonical_uri) = target(cfg, "");
// target 以空 key 生成的是「桶根」地址:虚拟主机风格即桶域名根,
// 路径风格即 {endpoint}/{bucket}/——HEAD 桶正合适
let payload_hash = sha256_hex(b"");
let (auth, amz_date, _) = signed_request(cfg, "HEAD", &host, &canonical_uri, None, &payload_hash);
let resp = client()?
.head(&url)
.header("Authorization", auth)
.header("x-amz-date", amz_date)
.header("x-amz-content-sha256", &payload_hash)
.send()
.map_err(|e| format!("连接 S3 失败:{e}"))?;
let status = resp.status();
if status.is_success() {
Ok("连接成功:存储桶可访问,密钥有效。".into())
} else if status.as_u16() == 403 {
Err("服务器返回 403:密钥不正确,或该密钥无此存储桶的访问权限。".into())
} else if status.as_u16() == 404 {
Err("服务器返回 404:存储桶不存在(检查桶名与区域)。".into())
} else {
Err(format!("服务器返回 {status}。"))
}
}
// ————————————————— SigV4 签名(已对照 AWS 官方示例向量验证) —————————————————
pub fn sha256_hex(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
hex(&h.finalize())
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC 接受任意长度密钥");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
/// 由 epoch 秒生成 SigV4 用的 UTC 时间串(amzDate 如 20150830T123600Z、dateStamp 如 20150830)。
pub fn amz_dates(epoch_secs: u64) -> (String, String) {
let days = (epoch_secs / 86_400) as i64;
let secs = epoch_secs % 86_400;
let (hh, mm, ss) = (secs / 3600, (secs % 3600) / 60, secs % 60);
// civil_from_days(Howard Hinnant)
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
(
format!("{year:04}{m:02}{d:02}T{hh:02}{mm:02}{ss:02}Z"),
format!("{year:04}{m:02}{d:02}"),
)
}
pub struct SignParams<'a> {
pub method: &'a str,
pub host: &'a str,
pub canonical_uri: &'a str,
pub canonical_query: &'a str,
pub region: &'a str,
pub service: &'a str,
pub access_key: &'a str,
pub secret_key: &'a str,
pub amz_date: &'a str,
pub date_stamp: &'a str,
pub payload_hash: &'a str,
/// 除 host / x-amz-date 外参与签名的请求头(名须小写)
pub extra_headers: &'a [(&'a str, &'a str)],
}
/// 计算 Authorization 头的完整值。
pub fn authorization_header(p: &SignParams) -> String {
let mut headers: Vec<(&str, &str)> = vec![("host", p.host), ("x-amz-date", p.amz_date)];
headers.extend_from_slice(p.extra_headers);
headers.sort_by(|a, b| a.0.cmp(b.0));
let canonical_headers: String = headers
.iter()
.map(|(k, v)| format!("{k}:{}\n", v.trim()))
.collect();
let signed_headers: String = headers
.iter()
.map(|(k, _)| *k)
.collect::<Vec<_>>()
.join(";");
let canonical_request = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
p.method, p.canonical_uri, p.canonical_query, canonical_headers, signed_headers, p.payload_hash
);
let scope = format!("{}/{}/{}/aws4_request", p.date_stamp, p.region, p.service);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
p.amz_date,
scope,
sha256_hex(canonical_request.as_bytes())
);
let k_date = hmac(format!("AWS4{}", p.secret_key).as_bytes(), p.date_stamp.as_bytes());
let k_region = hmac(&k_date, p.region.as_bytes());
let k_service = hmac(&k_region, p.service.as_bytes());
let k_signing = hmac(&k_service, b"aws4_request");
let signature = hex(&hmac(&k_signing, string_to_sign.as_bytes()));
format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
p.access_key, scope, signed_headers, signature
)
}
/// URI 路径编码(SigV4 canonical URI 与公开地址共用):保留 / 与 RFC 3986 非保留字符。
pub fn encode_uri_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for b in path.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}

View File

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

View File

@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync' import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
import { insertPlainText, insertLinkText } from './editor/paste' import { insertPlainText, insertLinkText } from './editor/paste'
import { isImeComposing } from './utils/ime' import { isImeComposing } from './utils/ime'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals' import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal } from './components/Modals'
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs' import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text' import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor' import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
@@ -29,13 +29,15 @@ import {
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry, statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, type GitStatusInfo, gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo,
} from './services/platform' } from './services/platform'
import * as store from './services/store' import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery' import { recoveryKey, planRecovery } from './utils/recovery'
import { appWindowClaims, appWindowId } from './services/windowClaims' import { appWindowClaims, appWindowId } from './services/windowClaims'
import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync' import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync'
import { GitSyncModal } from './components/GitSyncModal' import { GitSyncModal } from './components/GitSyncModal'
import { S3SettingsModal } from './components/S3SettingsModal'
import { DEFAULT_S3_SETTINGS, isS3Configured, isUploadableImageSrc, isDataImageSrc, parseDataImage, dataImageFileName, resolveDocRelativePath, imageFileName, imageDisplayName, s3ConfigPayload, type S3Settings } from './services/s3'
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals' import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
import { AppearanceModal, ExportModal } from './components/Stage5Modals' import { AppearanceModal, ExportModal } from './components/Stage5Modals'
import { DOC_THEMES, docThemeById } from './themes' import { DOC_THEMES, docThemeById } from './themes'
@@ -61,11 +63,13 @@ const UI_KEY = 'new-markdown:ui'
function loadRecent(): string[] { function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] } try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
} }
interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record<string, string> } interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record<string, string>; s3: S3Settings }
function loadUi(): UiState { function loadUi(): UiState {
const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {} } const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {}, s3: DEFAULT_S3_SETTINGS }
try { try {
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') } const parsed = JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') as Partial<UiState>
if (parsed.s3) parsed.s3 = { ...DEFAULT_S3_SETTINGS, ...parsed.s3 }
return { ...defaults, ...parsed }
} catch { return defaults } } catch { return defaults }
} }
@@ -472,11 +476,32 @@ export default function App() {
const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder]) 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 只把这份快照标为已保存, // 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存,
// 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。 // 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。
const snapshot = target.content const snapshot = target.content
const oldKey = recoveryKey(target)
try { try {
if (!tauriMode) { if (!tauriMode) {
downloadTextFile(`${target.title}.md`, snapshot) 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`)) path = await pickSaveFile(path ?? (workspaceRoot ? joinPath(workspaceRoot, `${target.title}.md`) : `${target.title}.md`))
if (!path) return false if (!path) return false
} }
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom) // 非 UTF-8 文档保存会转码为 UTF-8:首次保存前提示一次(按路径记已确认)。
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) }) // 自动保存不弹窗、也不静默转码——跳过本轮,等手动保存/关窗保存时提示。
// 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树 if (target.encoding !== 'UTF-8' && !encodingAckedRef.current.has(path)) {
if (target.path !== path) setTreeRefresh((n) => n + 1) if (fromAutosave) return false
const stat = await statFile(path) setEncodingPrompt({ target, path, snapshot })
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size }) return false
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
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) await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
await finishSave(target, path, snapshot)
return true return true
} catch (e) { } catch (e) {
setError(String(e)) setError(String(e))
return false 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 requestClose = useCallback((ids: string[], after: () => void) => {
const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t)) 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() const uiJson = await store.loadUiPref()
if (!cancelled && uiJson) { if (!cancelled && uiJson) {
try { setUi((prev) => ({ ...prev, ...JSON.parse(uiJson) })) } catch { /* 偏好损坏时用默认值 */ } try {
const parsed = JSON.parse(uiJson) as Partial<UiState>
if (parsed.s3) parsed.s3 = { ...DEFAULT_S3_SETTINGS, ...parsed.s3 }
setUi((prev) => ({ ...prev, ...parsed }))
} catch { /* 偏好损坏时用默认值 */ }
} }
uiLoadedRef.current = true uiLoadedRef.current = true
const recentFromDb = await store.loadRecent() const recentFromDb = await store.loadRecent()
@@ -731,7 +770,7 @@ export default function App() {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
for (const t of stateRef.current.tabs) { 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) }, Math.max(10, ui.autosaveSeconds) * 1000)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
@@ -1047,7 +1086,7 @@ 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 } | null>(null)
const openEditorMenu = (e: React.MouseEvent) => { const openEditorMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
const inEditor = sourceMode const inEditor = sourceMode
@@ -1056,15 +1095,19 @@ export default function App() {
if (!inEditor) return if (!inEditor) return
e.preventDefault() e.preventDefault()
let hasSelection = false let hasSelection = false
let imageSrc: string | null = null
if (sourceMode) { if (sourceMode) {
hasSelection = sourceApiRef.current?.hasSelection() ?? false hasSelection = sourceApiRef.current?.hasSelection() ?? false
} else if (tab) { } else if (tab) {
const editor = paneApisRef.current.get(tab.id)?.editor const api = paneApisRef.current.get(tab.id)
if (editor) { if (api?.editor) {
try { hasSelection = editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false } try { hasSelection = api.editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false }
} }
// 右键命中图片时取出它的文档 src:本地未上传图会在菜单里多一项「上传到 S3」
const img = target.closest('img')
if (img && api) imageSrc = api.imageSrcForElement(img)
} }
setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection }) setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection, imageSrc })
} }
const runEditorMenuAction = (action: EditorMenuAction) => { const runEditorMenuAction = (action: EditorMenuAction) => {
switch (action) { switch (action) {
@@ -1078,6 +1121,7 @@ export default function App() {
case 'inlineCode': withEditor(editorCommands.inlineCode); break case 'inlineCode': withEditor(editorCommands.inlineCode); break
case 'link': setModal('link'); break case 'link': setModal('link'); break
case 'toggleSource': toggleSourceMode(); break case 'toggleSource': toggleSourceMode(); break
case 'uploadS3': { const src = editorMenu?.imageSrc; if (src) void runSingleImageUpload(src); break }
} }
} }
@@ -1097,6 +1141,8 @@ export default function App() {
const stateRef = useRef(state) const stateRef = useRef(state)
stateRef.current = state stateRef.current = state
const uiRef = useRef(ui)
uiRef.current = ui
// 活动标签变化时登记保活顺序(最近用过的排前面,最多 6 个;超出的卸载走最终回写同步) // 活动标签变化时登记保活顺序(最近用过的排前面,最多 6 个;超出的卸载走最终回写同步)
const activeIdForAlive = tab?.id const activeIdForAlive = tab?.id
@@ -1124,21 +1170,114 @@ export default function App() {
} }
}, [activeIdForAlive]) }, [activeIdForAlive])
// ——— S3 图片上传(docs/30) ———
const [s3SettingsOpen, setS3SettingsOpen] = useState(false)
const [s3Progress, setS3Progress] = useState<{ total: number; done: number; current: string; errors: string[]; finished: boolean } | null>(null)
const s3BusyRef = useRef(false)
/**
* 上传一张未上传图片,返回 S3 公开地址:base64 内嵌图直接传字节(不需
* 文档落盘);本地文件图按 docPath + 相对 src 定位文件。
*/
const uploadImageSrc = useCallback(async (docPath: string | null, src: string): Promise<string> => {
const cfg = s3ConfigPayload(uiRef.current.s3)
const data = parseDataImage(src)
if (data) {
const name = dataImageFileName(src)
if (!name) throw new Error('无法识别内嵌图片格式')
return s3UploadBase64(cfg, data.base64, name)
}
if (!docPath) throw new Error('文档尚未保存,无法定位本地图片文件')
return s3UploadImage(cfg, resolveDocRelativePath(docPath, src), imageFileName(src))
}, [])
/** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */
const runSingleImageUpload = useCallback(async (src: string) => {
const current = activeTab(stateRef.current)
if (!current) return
if (!isDataImageSrc(src) && !current.path) { setError('请先保存文档,再上传本地图片。'); return }
if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
flash('正在上传图片…')
try {
const url = await uploadImageSrc(current.path, src)
paneApisRef.current.get(current.id)?.replaceImageSrc(src, url)
flash('图片已上传到 S3,引用已更新')
} catch (e) { setError(e instanceof Error ? e.message : String(e)) }
}, [flash, uploadImageSrc])
/** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */
const runBatchImageUpload = async () => {
if (!tab) return
if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return }
if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
if (s3BusyRef.current) return
const api = paneApisRef.current.get(tab.id)
if (!api) return
const locals = [...new Set(api.listImages().filter(isUploadableImageSrc))]
if (locals.length === 0) { flash('本文档没有未上传的图片'); return }
const docPath = tab.path
s3BusyRef.current = true
setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false })
const errors: string[] = []
let done = 0
for (const src of locals) {
setS3Progress((pr) => (pr ? { ...pr, current: imageDisplayName(src) } : pr))
try {
const url = await uploadImageSrc(docPath, src)
api.replaceImageSrc(src, url)
} catch (e) {
errors.push(`${imageDisplayName(src)}:${e instanceof Error ? e.message : String(e)}`)
}
done += 1
setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr))
}
s3BusyRef.current = false
setS3Progress((pr) => (pr ? { ...pr, current: '', errors, finished: true } : pr))
}
/**
* 自动上传(设置开关):图片落盘后在后台上传,成功后把引用换成 S3 地址。
* 粘贴建节点是异步的,第一次替换可能扑空,900ms 后重试一次;仍扑空则
* 保留本地引用并提示可用批量上传同步,不静默。
*/
const autoUploadSwap = useCallback((tabId: string, docPath: string | null, relSrc: string) => {
const settings = uiRef.current.s3
if (!isTauri() || !settings.autoUpload || !isS3Configured(settings)) return
void (async () => {
try {
const url = await uploadImageSrc(docPath, relSrc)
let n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0
if (n === 0) {
await new Promise((r) => setTimeout(r, 900))
n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0
}
if (n === 0) flash('图片已上传到 S3,但引用替换未生效,可用「上传文档图片到 S3」再同步一次')
} catch (e) {
flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`)
}
})()
}, [flash, uploadImageSrc])
/** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */ /** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */
const handleImageFile = useCallback(async (file: File): Promise<string> => { const handleImageFile = useCallback(async (file: File): Promise<string> => {
const current = activeTab(stateRef.current) const current = activeTab(stateRef.current)
if (isTauri() && current?.path) { if (isTauri() && current?.path) {
const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg') const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg')
const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}` const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}`
return saveBytesToAssets(current.path, named, await fileToBase64(file)) const rel = await saveBytesToAssets(current.path, named, await fileToBase64(file))
autoUploadSwap(current.id, current.path, rel)
return rel
} }
return new Promise((resolve) => { const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => resolve(String(reader.result)) reader.onload = () => resolve(String(reader.result))
reader.onerror = () => resolve(URL.createObjectURL(file)) reader.onerror = () => resolve(URL.createObjectURL(file))
reader.readAsDataURL(file) reader.readAsDataURL(file)
}) })
}, []) // 未落盘文档的粘贴图是 base64 内嵌形态:开了自动上传同样传走并回换
if (current && dataUrl.startsWith('data:')) autoUploadSwap(current.id, current.path ?? null, dataUrl)
return dataUrl
}, [autoUploadSwap])
const insertImage = useCallback(async () => { const insertImage = useCallback(async () => {
if (!tab) return if (!tab) return
@@ -1149,12 +1288,13 @@ export default function App() {
try { try {
const rel = await copyToAssets(picked, tab.path) const rel = await copyToAssets(picked, tab.path)
withEditor((e) => editorCommands.image(e, rel, baseName(picked))) withEditor((e) => editorCommands.image(e, rel, baseName(picked)))
autoUploadSwap(tab.id, tab.path, rel)
} catch (e) { setError(String(e)) } } catch (e) { setError(String(e)) }
} else { } else {
const picked = await pickAndReadWebImage() const picked = await pickAndReadWebImage()
if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name)) if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name))
} }
}, [tab, tauriMode, withEditor]) }, [tab, tauriMode, withEditor, autoUploadSwap])
const insertAttachment = useCallback(async () => { const insertAttachment = useCallback(async () => {
if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return } if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return }
@@ -1363,7 +1503,7 @@ export default function App() {
}, [tauriMode, flash]) }, [tauriMode, flash])
anyModalRef.current = Boolean( anyModalRef.current = Boolean(
modal || appearanceOpen || exportOpen || pendingClose || conflict modal || appearanceOpen || exportOpen || pendingClose || conflict || encodingPrompt
|| promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed), || promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
) )
findOpenRef.current = findUi.open findOpenRef.current = findUi.open
@@ -1376,6 +1516,16 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[tab?.id, deferredContent], [tab?.id, deferredContent],
) )
// 状态栏「未上传图片」计数:扫当前文档序列化文本里的本地图片引用(去重)
const pendingImages = useMemo(() => {
if (!tab?.content || sourceMode) return 0
const found = new Set<string>()
const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
let m: RegExpExecArray | null
while ((m = re.exec(tab.content))) if (isUploadableImageSrc(m[1])) found.add(m[1])
return found.size
}, [tab?.id, tab?.content, sourceMode])
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
if (!tab) return { words: 0, chars: 0, lines: 0 } if (!tab) return { words: 0, chars: 0, lines: 0 }
return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length } return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length }
@@ -1399,6 +1549,9 @@ export default function App() {
{ label: '导入 HTML / Word…', onClick: () => void importFileFlow() }, { label: '导入 HTML / Word…', onClick: () => void importFileFlow() },
{ label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) }, { label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) },
{ label: '打印 / 导出 PDF…', shortcut: 'Ctrl+P', disabled: !tab, onClick: printDoc }, { label: '打印 / 导出 PDF…', shortcut: 'Ctrl+P', disabled: !tab, onClick: printDoc },
{ divider: true },
{ label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() },
{ label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) },
], ],
}, },
{ {
@@ -1703,6 +1856,8 @@ export default function App() {
sourceMode={sourceMode} sourceMode={sourceMode}
tauriMode={tauriMode} tauriMode={tauriMode}
zoom={zoom} zoom={zoom}
pendingImages={pendingImages}
onUploadImages={() => void runBatchImageUpload()}
/> />
{gitSettingsOpen && workspaceRoot && ( {gitSettingsOpen && workspaceRoot && (
<GitSyncModal <GitSyncModal
@@ -1716,6 +1871,33 @@ export default function App() {
onClose={() => setGitSettingsOpen(false)} onClose={() => setGitSettingsOpen(false)}
/> />
)} )}
{s3SettingsOpen && (
<S3SettingsModal
initial={ui.s3}
onSave={(next) => { setUi((u) => ({ ...u, s3: next })); setS3SettingsOpen(false); flash('S3 设置已保存') }}
onClose={() => setS3SettingsOpen(false)}
/>
)}
{s3Progress && (
<div className="modal-backdrop">
<div className="modal">
<h3>上传图片到 S3</h3>
{!s3Progress.finished ? (
<p className="modal-note">正在上传 {Math.min(s3Progress.done + 1, s3Progress.total)} / {s3Progress.total}:{s3Progress.current}</p>
) : (
<>
<p className="modal-note">完成:成功 {s3Progress.total - s3Progress.errors.length} 张,失败 {s3Progress.errors.length} 张。</p>
{s3Progress.errors.map((err, i) => (
<p key={i} className="modal-note" style={{ color: 'var(--danger, #c0392b)' }}>{err}</p>
))}
</>
)}
<div className="modal-actions">
<button type="button" disabled={!s3Progress.finished} onClick={() => setS3Progress(null)}>关闭</button>
</div>
</div>
</div>
)}
{appearanceOpen && ( {appearanceOpen && (
<AppearanceModal <AppearanceModal
docTheme={ui.docTheme} docTheme={ui.docTheme}
@@ -1754,6 +1936,7 @@ export default function App() {
x={editorMenu.x} x={editorMenu.x}
y={editorMenu.y} y={editorMenu.y}
hasSelection={editorMenu.hasSelection} hasSelection={editorMenu.hasSelection}
imageSrc={editorMenu.imageSrc}
sourceMode={sourceMode} sourceMode={sourceMode}
onAction={runEditorMenuAction} onAction={runEditorMenuAction}
onClose={() => setEditorMenu(null)} onClose={() => setEditorMenu(null)}
@@ -1857,6 +2040,15 @@ export default function App() {
/> />
)} )}
{error && <ErrorModal message={error} onClose={() => setError(null)} />} {error && <ErrorModal message={error} onClose={() => setError(null)} />}
{encodingPrompt && (
<EncodingConvertModal
encoding={encodingPrompt.target.encoding}
fileName={baseName(encodingPrompt.path)}
onConfirm={() => { void confirmEncodingSave() }}
onCancel={() => setEncodingPrompt(null)}
/>
)}
</div> </div>
) )
} }

View File

@@ -1,11 +1,21 @@
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。 // 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、 // 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。 // 选中 --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' import { useDismiss } from '../hooks/useDismiss'
export interface DropdownOption { value: string; label: string } export interface DropdownOption { value: string; label: string }
interface MenuPos { left: number; top: number; width: number; maxHeight: number }
const ITEM_H = 31
const MENU_PAD = 8
const MAX_H = 264
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: { export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
value: string value: string
options: DropdownOption[] options: DropdownOption[]
@@ -14,13 +24,35 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
ariaLabel?: string ariaLabel?: string
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [pos, setPos] = useState<MenuPos | null>(null)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const close = useCallback(() => setOpen(false), []) 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 current = options.find((o) => o.value === value) ?? options[0]
// 打开时按触发钮位置算展开层几何:优先向下,下方放不下且上方更宽裕时翻上去
const openMenu = useCallback(() => {
const rect = btnRef.current?.getBoundingClientRect()
if (!rect) { setOpen(true); return }
const est = Math.min(MAX_H, options.length * ITEM_H + MENU_PAD)
const spaceBelow = window.innerHeight - rect.bottom - 8
const spaceAbove = rect.top - 8
const above = spaceBelow < est && spaceAbove > spaceBelow
const maxHeight = Math.max(80, Math.min(MAX_H, above ? spaceAbove : spaceBelow))
const height = Math.min(est, maxHeight)
setPos({
left: Math.max(4, Math.min(rect.left, window.innerWidth - rect.width - 4)),
top: above ? Math.max(4, rect.top - height - 4) : rect.bottom + 4,
width: rect.width,
maxHeight,
})
setOpen(true)
}, [options.length])
// 打开时把焦点放到选中项,便于直接上下键选行 // 打开时把焦点放到选中项,便于直接上下键选行
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -29,6 +61,14 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open ]) }, [open ])
// 窗口尺寸变化时展开层位置失效,直接关闭(与菜单类组件惯例一致)
useEffect(() => {
if (!open) return
const onResize = () => setOpen(false)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [open ])
const pick = (v: string) => { setOpen(false); onChange(v) } const pick = (v: string) => { setOpen(false); onChange(v) }
const onMenuKey = (e: React.KeyboardEvent) => { const onMenuKey = (e: React.KeyboardEvent) => {
@@ -46,17 +86,23 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
<div className="dropdown" ref={ref}> <div className="dropdown" ref={ref}>
<button <button
type="button" type="button"
ref={btnRef}
className="dropdown-button" className="dropdown-button"
disabled={disabled} disabled={disabled}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((o) => !o)} onClick={() => { if (open) setOpen(false); else openMenu() }}
> >
<span className="dropdown-label">{current?.label ?? ''}</span> <span className="dropdown-label">{current?.label ?? ''}</span>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg> <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button> </button>
{open && ( {open && pos && createPortal(
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}> <div
className="dropdown-menu"
ref={menuRef}
onKeyDown={onMenuKey}
style={{ position: 'fixed', left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight, zIndex: 200 }}
>
{options.map((o) => ( {options.map((o) => (
<button <button
key={o.value} key={o.value}
@@ -67,7 +113,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
{o.label} {o.label}
</button> </button>
))} ))}
</div> </div>,
document.body,
)} )}
</div> </div>
) )

View File

@@ -1,17 +1,20 @@
import { useRef } from 'react' import { useRef } from 'react'
import { useDismiss } from '../hooks/useDismiss' import { useDismiss } from '../hooks/useDismiss'
import { clampMenuPosition } from '../utils/menuPos' import { clampMenuPosition } from '../utils/menuPos'
import { isUploadableImageSrc } from '../services/s3'
export type EditorMenuAction = export type EditorMenuAction =
| 'cut' | 'copy' | 'paste' | 'selectAll' | 'cut' | 'copy' | 'paste' | 'selectAll'
| 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link' | 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link'
| 'toggleSource' | 'toggleSource' | 'uploadS3'
interface Props { interface Props {
x: number x: number
y: number y: number
/** 当前是否有选区(决定剪切/复制是否可用) */ /** 当前是否有选区(决定剪切/复制是否可用) */
hasSelection: boolean hasSelection: boolean
/** 右键命中的图片节点 src(非图片为 null);本地未上传图时多一项「上传到 S3」 */
imageSrc: string | null
sourceMode: boolean sourceMode: boolean
onAction: (action: EditorMenuAction) => void onAction: (action: EditorMenuAction) => void
onClose: () => void onClose: () => void
@@ -22,12 +25,13 @@ interface Props {
* 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里, * 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里,
* execCommand 类命令才作用得准。 * 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) const ref = useRef<HTMLDivElement>(null)
useDismiss(ref, true, onClose) useDismiss(ref, true, onClose)
const act = (a: EditorMenuAction) => { onClose(); onAction(a) } const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式) const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
const { left, top } = clampMenuPosition(x, y, 188, sourceMode ? 236 : 388) // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项)
const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0))
return ( return (
<div <div
className="tree-context-menu editor-context-menu" className="tree-context-menu editor-context-menu"
@@ -51,6 +55,12 @@ export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAc
<button onClick={() => act('link')}>超链接…</button> <button onClick={() => act('link')}>超链接…</button>
</> </>
)} )}
{showUpload && (
<>
<div className="tree-menu-divider" />
<button onClick={() => act('uploadS3')}>上传到 S3</button>
</>
)}
<div className="tree-menu-divider" /> <div className="tree-menu-divider" />
<button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button> <button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button>
</div> </div>

View File

@@ -3,7 +3,7 @@ import TableToolbar from './TableToolbar'
import type { Editor } from '@milkdown/kit/core' import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core' import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state' import { TextSelection } from '@milkdown/kit/prose/state'
import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/editor' import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc as replaceImageSrcInEditor, imageSrcAtElement, type CursorInfo } from '../editor/editor'
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync' import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search' import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins' import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
@@ -19,6 +19,12 @@ export interface EditorPaneApi {
replaceCurrent: (replacement: string) => boolean replaceCurrent: (replacement: string) => boolean
replaceAll: (replacement: string) => number replaceAll: (replacement: string) => number
clearFind: () => void clearFind: () => void
/** 文档里全部图片 src(去重)——S3 上传扫描用 */
listImages: () => string[]
/** 把 src 为 oldSrc 的图片节点全部换成 newSrc,返回替换数 */
replaceImageSrc: (oldSrc: string, newSrc: string) => number
/** 某 DOM 元素对应的图片节点 src(右键命中图片判定) */
imageSrcForElement: (el: HTMLElement) => string | null
} }
interface Props { interface Props {
@@ -48,6 +54,9 @@ const EMPTY_API: EditorPaneApi = {
replaceCurrent: () => false, replaceCurrent: () => false,
replaceAll: () => 0, replaceAll: () => 0,
clearFind: () => undefined, clearFind: () => undefined,
listImages: () => [],
replaceImageSrc: () => 0,
imageSrcForElement: () => null,
} }
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */ /** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
@@ -231,6 +240,13 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
const clearFind = () => { const clearFind = () => {
findRef.current = { matches: [], current: -1, query: '', opts: null } findRef.current = { matches: [], current: -1, query: '', opts: null }
} }
const listImages = (): string[] => {
try { return collectImageSrcs(editor) } catch { return [] }
}
const replaceImageSrc = (oldSrc: string, newSrc: string): number => {
try { return replaceImageSrcInEditor(editor, oldSrc, newSrc) } catch { return 0 }
}
const imageSrcForElement = (el: HTMLElement): string | null => imageSrcAtElement(editor, el)
editor.create() editor.create()
.then(() => { .then(() => {
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已 // 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
@@ -241,7 +257,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
try { try {
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
} catch { /* 忽略 */ } } catch { /* 忽略 */ }
callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind }) callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind, listImages, replaceImageSrc, imageSrcForElement })
if (chunkLoad) { if (chunkLoad) {
setLoadProgress({ done: 1, total: chunkLoad.totalChunks }) setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
void chunkLoad.appendRest( void chunkLoad.appendRest(

View File

@@ -45,7 +45,7 @@ export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialInterval
<input <input
className="input" className="input"
value={remoteUrl} value={remoteUrl}
placeholder="https://git.xhome.cc/xieyinlu/notes.git" placeholder="https://github.com/example/example.git"
onChange={(e) => setRemoteUrl(e.target.value)} onChange={(e) => setRemoteUrl(e.target.value)}
/> />
</label> </label>

View File

@@ -28,6 +28,24 @@ export function ConfirmSaveModal({ title, onSave, onDiscard, onCancel }: {
) )
} }
export function EncodingConvertModal({ encoding, fileName, onConfirm, onCancel }: {
encoding: string
fileName: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<Shell onCancel={onCancel}>
<h3>文档编码将转为 UTF-8</h3>
<p>「{fileName}」原是 {encoding} 编码。本编辑器统一以 UTF-8 保存,保存后文件编码将转为 UTF-8(内容不变)。本次运行内,同一文件只提示这一次。</p>
<div className="modal-actions">
<button onClick={onCancel}>取消保存</button>
<button className="primary" onClick={onConfirm}>转为 UTF-8 保存</button>
</div>
</Shell>
)
}
export function AboutModal({ onClose }: { onClose: () => void }) { export function AboutModal({ onClose }: { onClose: () => void }) {
return ( return (
<Shell onCancel={onClose}> <Shell onCancel={onClose}>

View File

@@ -0,0 +1,132 @@
// S3 图片上传设置(docs/30):服务端点/区域/存储桶/密钥/前缀/访问域名,
// 以及「自动上传图片」开关。密钥只保存在本机偏好库里,随调用传给 Rust
// 签名上传,不写进任何文档、也不会被 Git 同步带走。
import { useEffect, useState } from 'react'
import { s3TestConnection } from '../services/platform'
import { s3ConfigPayload, type S3Settings } from '../services/s3'
export function S3SettingsModal({ initial, onSave, onClose }: {
initial: S3Settings
onSave: (next: S3Settings) => void
onClose: () => void
}) {
const [draft, setDraft] = useState<S3Settings>(initial)
const [testState, setTestState] = useState<{ kind: 'idle' | 'busy' | 'ok' | 'err'; text: string }>({ kind: 'idle', text: '' })
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const set = (patch: Partial<S3Settings>) => {
setDraft((d) => ({ ...d, ...patch }))
setTestState({ kind: 'idle', text: '' })
}
const test = async () => {
setTestState({ kind: 'busy', text: '正在连接…' })
try {
const msg = await s3TestConnection(s3ConfigPayload(draft))
setTestState({ kind: 'ok', text: msg })
} catch (e) {
setTestState({ kind: 'err', text: e instanceof Error ? e.message : String(e) })
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>S3 图片上传设置</h3>
<p className="modal-note">
图片上传到你自己的 S3 兼容存储(AWS S3、MinIO、Cloudflare R2、阿里云 OSS 等),
文档里的图片引用随之换成公开访问地址。密钥只保存在本机设置里,不会写进文档。
</p>
<label className="modal-field">
<span>服务端点(Endpoint,可空 = AWS S3)</span>
<input
className="input"
value={draft.endpoint}
placeholder="https://s3.example.com"
onChange={(e) => set({ endpoint: e.target.value })}
/>
</label>
<label className="modal-field">
<span>区域(Region)*</span>
<input
className="input"
value={draft.region}
placeholder="us-east-1"
onChange={(e) => set({ region: e.target.value })}
/>
</label>
<label className="modal-field">
<span>存储桶(Bucket)*</span>
<input
className="input"
value={draft.bucket}
placeholder="my-images"
onChange={(e) => set({ bucket: e.target.value })}
/>
</label>
<label className="modal-field">
<span>Access Key *</span>
<input
className="input"
value={draft.accessKey}
autoComplete="off"
onChange={(e) => set({ accessKey: e.target.value })}
/>
</label>
<label className="modal-field">
<span>Secret Key *</span>
<input
className="input"
type="password"
value={draft.secretKey}
autoComplete="off"
onChange={(e) => set({ secretKey: e.target.value })}
/>
</label>
<label className="modal-field">
<span>路径前缀(可空,对象键 = 前缀 + 文件名)</span>
<input
className="input"
value={draft.prefix}
placeholder="notes/"
onChange={(e) => set({ prefix: e.target.value })}
/>
</label>
<label className="modal-field">
<span>自定义访问域名(可空;回填地址 = 此域名 / 存储桶 / 前缀 / 文件名,适合 MinIO 等 API 与访问地址分离的部署)</span>
<input
className="input"
value={draft.publicBase}
placeholder="https://cdn.example.com"
onChange={(e) => set({ publicBase: e.target.value })}
/>
</label>
<div className="setting-row">
<label>
<input
type="checkbox"
checked={draft.autoUpload}
onChange={(e) => set({ autoUpload: e.target.checked })}
/>
自动上传图片(粘贴/插入的图片落盘后自动上传,并换成 S3 地址)
</label>
</div>
{testState.kind !== 'idle' && (
<p className="modal-note" style={testState.kind === 'err' ? { color: 'var(--danger, #c0392b)' } : undefined}>
{testState.text}
</p>
)}
<div className="modal-actions">
<button type="button" onClick={() => void test()} disabled={testState.kind === 'busy'}>测试连接</button>
<button type="button" onClick={onClose}>取消</button>
<button type="button" className="primary" onClick={() => onSave(draft)}>保存</button>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -165,6 +165,62 @@ 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 function callUndo(editor: Editor) { export function callUndo(editor: Editor) {
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key)) editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
} }

View File

@@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react'
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。 * 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行, * 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
* 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。 * 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
* refs 可传多个:菜单经 Portal 渲染到 body 时(如下拉展开层),触发器与
* 菜单是两个分离的 DOM 子树,两边都算「内」。
*/ */
export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean, onClose: () => void) { export function useDismiss(
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
active: boolean,
onClose: () => void,
) {
useEffect(() => { useEffect(() => {
if (!active) return if (!active) return
const outside = (target: EventTarget | null) => const list = Array.isArray(refs) ? refs : [refs]
ref.current && target instanceof Node && !ref.current.contains(target) const outside = (target: EventTarget | null) => {
if (!(target instanceof Node)) return false
const containers = list.map((r) => r.current).filter(Boolean) as HTMLElement[]
return containers.length > 0 && containers.every((el) => !el.contains(target))
}
const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() } const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
const onScroll = (e: Event) => { if (outside(e.target)) onClose() } const onScroll = (e: Event) => { if (outside(e.target)) onClose() }
@@ -26,5 +36,5 @@ export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean,
document.removeEventListener('scroll', onScroll, true) document.removeEventListener('scroll', onScroll, true)
window.removeEventListener('blur', onClose) window.removeEventListener('blur', onClose)
} }
}, [ref, active, onClose]) }, [refs, active, onClose])
} }

View File

@@ -348,3 +348,30 @@ export async function gitSetRemote(root: string, remoteUrl: string): Promise<Git
export async function gitSyncNow(root: string, message: string): Promise<GitSyncOutcome> { export async function gitSyncNow(root: string, message: string): Promise<GitSyncOutcome> {
return invoke<GitSyncOutcome>('git_sync_now', { root, message }) return invoke<GitSyncOutcome>('git_sync_now', { root, message })
} }
// ——— S3 图片上传(docs/30):配置随调用传入,Rust 侧签名 + PUT,不保存密钥 ———
export interface S3ConfigPayload {
endpoint: string
region: string
bucket: string
accessKey: string
secretKey: string
prefix: string
publicBase: string
}
/** 上传一个本地图片文件,返回应回填文档的公开访问地址。 */
export async function s3UploadImage(config: S3ConfigPayload, filePath: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_image', { config, filePath, fileName })
}
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_base64', { config, base64, fileName })
}
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
return invoke<string>('s3_test_connection', { config })
}

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

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

View File

@@ -46,7 +46,7 @@ export type TabsAction =
| { type: 'open'; tab: DocTab } | { type: 'open'; tab: DocTab }
| { type: 'activate'; id: string } | { type: 'activate'; id: string }
| { type: 'updateContent'; id: string; content: string } | { type: 'updateContent'; id: string; content: string }
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string } | { type: 'saved'; id: string; savedContent: string; path?: string; title?: string; encoding?: string }
| { type: 'syncEditorContent'; id: string; content: string } | { type: 'syncEditorContent'; id: string; content: string }
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean } | { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
| { type: 'setPath'; id: string; path: string; title: string } | { type: 'setPath'; id: string; path: string; title: string }
@@ -93,7 +93,7 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
...state, ...state,
tabs: state.tabs.map((t) => tabs: state.tabs.map((t) =>
t.id === action.id t.id === action.id
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title } ? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title, encoding: action.encoding ?? t.encoding }
: t, : t,
), ),
} }

View File

@@ -97,6 +97,8 @@ button { font: inherit; color: inherit; }
.sidebar-tabs .sidebar-collapse:hover { color: var(--text); background: var(--hover); } .sidebar-tabs .sidebar-collapse:hover { color: var(--text); background: var(--hover); }
.sidebar-expand { position: absolute; left: 10px; top: 10px; z-index: 35; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 7px; color: var(--text-dim); cursor: pointer; } .sidebar-expand { position: absolute; left: 10px; top: 10px; z-index: 35; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 7px; color: var(--text-dim); cursor: pointer; }
.sidebar-expand:hover { color: var(--text); border-color: var(--accent); } .sidebar-expand:hover { color: var(--text); border-color: var(--accent); }
.status-upload-btn { background: transparent; border: 1px solid var(--border); color: var(--text-dim); border-radius: 999px; padding: 1px 9px; font-size: 11.5px; font-family: inherit; cursor: pointer; }
.status-upload-btn:hover { color: var(--accent); border-color: var(--accent); }
.source-toggle { position: absolute; right: 14px; bottom: 14px; z-index: 35; background: var(--bg-panel); color: var(--text-dim); border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.10); } .source-toggle { position: absolute; right: 14px; bottom: 14px; z-index: 35; background: var(--bg-panel); color: var(--text-dim); border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.10); }
.source-toggle:hover { color: var(--text); border-color: var(--accent); } .source-toggle:hover { color: var(--text); border-color: var(--accent); }
.tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } .tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
@@ -358,7 +360,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.dropdown-button:disabled { opacity: 0.5; cursor: default; } .dropdown-button:disabled { opacity: 0.5; cursor: default; }
.dropdown-button svg { flex: none; color: var(--text-dim); } .dropdown-button svg { flex: none; color: var(--text-dim); }
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; max-height: 264px; overflow-y: auto; } /* 展开层几何(fixed 定位/宽/最大高)由 Dropdown 组件内联给出:Portal 到 body 逃出弹窗等 overflow 容器的裁剪 */
.dropdown-menu { background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow-y: auto; }
.dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; } .dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; }
.dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; } .dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
.dropdown-menu button.selected { background: var(--accent-weak); } .dropdown-menu button.selected { background: var(--accent-weak); }

View File

@@ -22,18 +22,44 @@ describe('Dropdown', () => {
const button = container.querySelector('.dropdown-button') as HTMLButtonElement const button = container.querySelector('.dropdown-button') as HTMLButtonElement
expect(button.textContent).toContain('GitHub') expect(button.textContent).toContain('GitHub')
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
const menu = container.querySelector('.dropdown-menu') const menu = document.querySelector('.dropdown-menu')
expect(menu).not.toBeNull() expect(menu).not.toBeNull()
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub') expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion') const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onChange).toHaveBeenCalledWith('notion') expect(onChange).toHaveBeenCalledWith('notion')
expect(container.querySelector('.dropdown-menu')).toBeNull() expect(document.querySelector('.dropdown-menu')).toBeNull()
// 再次展开,点别处关闭 // 再次展开,点别处关闭
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(container.querySelector('.dropdown-menu')).not.toBeNull() expect(document.querySelector('.dropdown-menu')).not.toBeNull()
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
expect(document.querySelector('.dropdown-menu')).toBeNull()
await act(async () => { root.unmount() })
container.remove()
})
it('展开层经 Portal 挂到 body;触发钮靠近窗口底部时向上翻且不溢出', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(<Dropdown value="github" options={OPTIONS} onChange={() => undefined} />)
})
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
// 模拟触发钮贴近窗口底部(jsdom 默认视口高 768):下方只剩 30px
button.getBoundingClientRect = () => ({
top: 700, bottom: 730, left: 100, right: 300, width: 200, height: 30, x: 100, y: 700, toJSON: () => ({}),
}) as DOMRect
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
// Portal:菜单不在组件容器内,而是 body 直属
expect(container.querySelector('.dropdown-menu')).toBeNull() expect(container.querySelector('.dropdown-menu')).toBeNull()
const menu = document.querySelector('.dropdown-menu') as HTMLElement | null
expect(menu).not.toBeNull()
expect(menu!.style.position).toBe('fixed')
// 3 项估算高 101,下方放不下 → 翻到触发钮上方:top = 700 - 101 - 4
expect(menu!.style.top).toBe('595px')
expect(menu!.style.left).toBe('100px')
expect(menu!.style.width).toBe('200px')
await act(async () => { root.unmount() }) await act(async () => { root.unmount() })
container.remove() container.remove()
}) })

View File

@@ -13,7 +13,7 @@ async function renderMenu(props: Record<string, unknown> = {}) {
await act(async () => { await act(async () => {
root.render( root.render(
<EditorContextMenu <EditorContextMenu
x={10} y={10} hasSelection={false} sourceMode={false} x={10} y={10} hasSelection={false} imageSrc={null} sourceMode={false}
onAction={(a) => actions.push(a)} onAction={(a) => actions.push(a)}
onClose={() => { closed += 1 }} onClose={() => { closed += 1 }}
{...props} {...props}
@@ -38,6 +38,20 @@ describe('编辑区右键菜单', () => {
container.remove() container.remove()
}) })
it('命中本地图片时多一项「上传到 S3」;网络图片不出现', async () => {
const local = await renderMenu({ imageSrc: 'doc.assets/a.png' })
const up = btn(local.container, '上传到 S3')
expect(up).toBeTruthy()
await act(async () => { up?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(local.actions).toEqual(['uploadS3'])
await act(async () => { local.root.unmount() })
local.container.remove()
const remote = await renderMenu({ imageSrc: 'https://cdn.example.com/a.png' })
expect(btn(remote.container, '上传到 S3')).toBeUndefined()
await act(async () => { remote.root.unmount() })
remote.container.remove()
})
it('源码模式只出编辑命令与「即时渲染」', async () => { it('源码模式只出编辑命令与「即时渲染」', async () => {
const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true }) const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true })
expect(btn(container, '加粗')).toBeUndefined() expect(btn(container, '加粗')).toBeUndefined()

View File

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

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

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

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

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

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