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、构建通过,设置弹窗截图核对。
This commit is contained in:
64
docs/30-S3图片上传.md
Normal file
64
docs/30-S3图片上传.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 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}`。对象键 = 前缀 + 原文件名(同名覆盖,
|
||||||
|
重复上传幂等)。配了「自定义访问域名」时回填文档的是该域名拼的地址。
|
||||||
|
- **配置存储**:进 UiState.s3(本机偏好库 SQLite/localStorage),字段
|
||||||
|
endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload;
|
||||||
|
加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。
|
||||||
|
弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。
|
||||||
|
- **「未上传」判定**:图片 src 不是 http(s)/data/blob 即本地引用
|
||||||
|
(`isLocalImageSrc`)。上传成功后把编辑器里所有同 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 算法)与空负载哈希一并断言。
|
||||||
|
- **协议端到端**:把随产品发货的同一份 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,按提示校时即可)。
|
||||||
403
src-tauri/Cargo.lock
generated
403
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -19,3 +19,6 @@ 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"
|
||||||
|
|||||||
@@ -925,3 +925,25 @@ 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_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("连接测试失败:{e}"))?
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
|
mod s3;
|
||||||
mod search;
|
mod search;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
@@ -66,6 +67,8 @@ 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_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,
|
||||||
|
|||||||
322
src-tauri/src/s3.rs
Normal file
322
src-tauri/src/s3.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
//! 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://cdn.example.com;填了则回填文档的
|
||||||
|
/// 是这个域名下的地址,否则用上传请求本身的地址
|
||||||
|
#[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}"))?;
|
||||||
|
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 {
|
||||||
|
Ok(format!("{base}{}", encode_uri_path(&format!("/{key}"))))
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
}
|
||||||
164
src/App.tsx
164
src/App.tsx
@@ -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, 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, isLocalImageSrc, resolveDocRelativePath, imageFileName, s3ConfigPayload, type S3Settings } from './services/s3'
|
||||||
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
|
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
|
||||||
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
|
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
|
||||||
import { DOC_THEMES, docThemeById } from './themes'
|
import { DOC_THEMES, docThemeById } from './themes'
|
||||||
@@ -61,11 +63,13 @@ const UI_KEY = 'new-markdown:ui'
|
|||||||
function loadRecent(): string[] {
|
function loadRecent(): string[] {
|
||||||
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
|
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
|
||||||
}
|
}
|
||||||
interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record<string, string> }
|
interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record<string, string>; s3: S3Settings }
|
||||||
function loadUi(): UiState {
|
function loadUi(): UiState {
|
||||||
const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {} }
|
const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {}, s3: DEFAULT_S3_SETTINGS }
|
||||||
try {
|
try {
|
||||||
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') }
|
const parsed = JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') as Partial<UiState>
|
||||||
|
if (parsed.s3) parsed.s3 = { ...DEFAULT_S3_SETTINGS, ...parsed.s3 }
|
||||||
|
return { ...defaults, ...parsed }
|
||||||
} catch { return defaults }
|
} catch { return defaults }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,7 +671,11 @@ export default function App() {
|
|||||||
// 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。
|
// 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。
|
||||||
const uiJson = await store.loadUiPref()
|
const uiJson = await store.loadUiPref()
|
||||||
if (!cancelled && uiJson) {
|
if (!cancelled && uiJson) {
|
||||||
try { setUi((prev) => ({ ...prev, ...JSON.parse(uiJson) })) } catch { /* 偏好损坏时用默认值 */ }
|
try {
|
||||||
|
const parsed = JSON.parse(uiJson) as Partial<UiState>
|
||||||
|
if (parsed.s3) parsed.s3 = { ...DEFAULT_S3_SETTINGS, ...parsed.s3 }
|
||||||
|
setUi((prev) => ({ ...prev, ...parsed }))
|
||||||
|
} catch { /* 偏好损坏时用默认值 */ }
|
||||||
}
|
}
|
||||||
uiLoadedRef.current = true
|
uiLoadedRef.current = true
|
||||||
const recentFromDb = await store.loadRecent()
|
const recentFromDb = await store.loadRecent()
|
||||||
@@ -1078,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
|
||||||
@@ -1087,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) {
|
||||||
@@ -1109,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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,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
|
||||||
@@ -1155,13 +1170,92 @@ 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)
|
||||||
|
|
||||||
|
/** 上传一张本地图片(docPath + 文档相对 src),返回 S3 公开地址。 */
|
||||||
|
const uploadLocalImage = useCallback(async (docPath: string, src: string): Promise<string> => {
|
||||||
|
const abs = resolveDocRelativePath(docPath, src)
|
||||||
|
return s3UploadImage(s3ConfigPayload(uiRef.current.s3), abs, imageFileName(src))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */
|
||||||
|
const runSingleImageUpload = useCallback(async (src: string) => {
|
||||||
|
const current = activeTab(stateRef.current)
|
||||||
|
if (!current?.path) { setError('请先保存文档,再上传图片。'); return }
|
||||||
|
if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
|
||||||
|
flash('正在上传图片…')
|
||||||
|
try {
|
||||||
|
const url = await uploadLocalImage(current.path, src)
|
||||||
|
paneApisRef.current.get(current.id)?.replaceImageSrc(src, url)
|
||||||
|
flash('图片已上传到 S3,引用已更新')
|
||||||
|
} catch (e) { setError(e instanceof Error ? e.message : String(e)) }
|
||||||
|
}, [flash, uploadLocalImage])
|
||||||
|
|
||||||
|
/** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */
|
||||||
|
const runBatchImageUpload = async () => {
|
||||||
|
if (!tab?.path) { setError('请先保存文档,再批量上传图片。'); 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(isLocalImageSrc))]
|
||||||
|
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: imageFileName(src) } : pr))
|
||||||
|
try {
|
||||||
|
const url = await uploadLocalImage(docPath, src)
|
||||||
|
api.replaceImageSrc(src, url)
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${imageFileName(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, relSrc: string) => {
|
||||||
|
const settings = uiRef.current.s3
|
||||||
|
if (!settings.autoUpload || !isS3Configured(settings)) return
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const url = await s3UploadImage(s3ConfigPayload(settings), resolveDocRelativePath(docPath, relSrc), imageFileName(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])
|
||||||
|
|
||||||
/** 粘贴/拖入图片的落盘:桌面模式写入文档 .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) => {
|
return new Promise((resolve) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -1169,7 +1263,7 @@ export default function App() {
|
|||||||
reader.onerror = () => resolve(URL.createObjectURL(file))
|
reader.onerror = () => resolve(URL.createObjectURL(file))
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [autoUploadSwap])
|
||||||
|
|
||||||
const insertImage = useCallback(async () => {
|
const insertImage = useCallback(async () => {
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
@@ -1180,12 +1274,13 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const rel = await copyToAssets(picked, tab.path)
|
const rel = await copyToAssets(picked, tab.path)
|
||||||
withEditor((e) => editorCommands.image(e, rel, baseName(picked)))
|
withEditor((e) => editorCommands.image(e, rel, baseName(picked)))
|
||||||
|
autoUploadSwap(tab.id, tab.path, rel)
|
||||||
} catch (e) { setError(String(e)) }
|
} catch (e) { setError(String(e)) }
|
||||||
} else {
|
} else {
|
||||||
const picked = await pickAndReadWebImage()
|
const picked = await pickAndReadWebImage()
|
||||||
if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name))
|
if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name))
|
||||||
}
|
}
|
||||||
}, [tab, tauriMode, withEditor])
|
}, [tab, tauriMode, withEditor, autoUploadSwap])
|
||||||
|
|
||||||
const insertAttachment = useCallback(async () => {
|
const insertAttachment = useCallback(async () => {
|
||||||
if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return }
|
if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return }
|
||||||
@@ -1407,6 +1502,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 (isLocalImageSrc(m[1])) found.add(m[1])
|
||||||
|
return found.size
|
||||||
|
}, [tab?.id, tab?.content, sourceMode])
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
if (!tab) return { words: 0, chars: 0, lines: 0 }
|
if (!tab) return { words: 0, chars: 0, lines: 0 }
|
||||||
return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length }
|
return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length }
|
||||||
@@ -1430,6 +1535,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) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1734,6 +1842,8 @@ export default function App() {
|
|||||||
sourceMode={sourceMode}
|
sourceMode={sourceMode}
|
||||||
tauriMode={tauriMode}
|
tauriMode={tauriMode}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
|
pendingImages={pendingImages}
|
||||||
|
onUploadImages={() => void runBatchImageUpload()}
|
||||||
/>
|
/>
|
||||||
{gitSettingsOpen && workspaceRoot && (
|
{gitSettingsOpen && workspaceRoot && (
|
||||||
<GitSyncModal
|
<GitSyncModal
|
||||||
@@ -1747,6 +1857,33 @@ export default function App() {
|
|||||||
onClose={() => setGitSettingsOpen(false)}
|
onClose={() => setGitSettingsOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{s3SettingsOpen && (
|
||||||
|
<S3SettingsModal
|
||||||
|
initial={ui.s3}
|
||||||
|
onSave={(next) => { setUi((u) => ({ ...u, s3: next })); setS3SettingsOpen(false); flash('S3 设置已保存') }}
|
||||||
|
onClose={() => setS3SettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{s3Progress && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<h3>上传图片到 S3</h3>
|
||||||
|
{!s3Progress.finished ? (
|
||||||
|
<p className="modal-note">正在上传 {Math.min(s3Progress.done + 1, s3Progress.total)} / {s3Progress.total}:{s3Progress.current}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="modal-note">完成:成功 {s3Progress.total - s3Progress.errors.length} 张,失败 {s3Progress.errors.length} 张。</p>
|
||||||
|
{s3Progress.errors.map((err, i) => (
|
||||||
|
<p key={i} className="modal-note" style={{ color: 'var(--danger, #c0392b)' }}>{err}</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" disabled={!s3Progress.finished} onClick={() => setS3Progress(null)}>关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{appearanceOpen && (
|
{appearanceOpen && (
|
||||||
<AppearanceModal
|
<AppearanceModal
|
||||||
docTheme={ui.docTheme}
|
docTheme={ui.docTheme}
|
||||||
@@ -1785,6 +1922,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)}
|
||||||
|
|||||||
@@ -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 { isLocalImageSrc } 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 && isLocalImageSrc(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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
132
src/components/S3SettingsModal.tsx
Normal file
132
src/components/S3SettingsModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// S3 图片上传设置(docs/30):服务端点/区域/存储桶/密钥/前缀/访问域名,
|
||||||
|
// 以及「自动上传图片」开关。密钥只保存在本机偏好库里,随调用传给 Rust
|
||||||
|
// 签名上传,不写进任何文档、也不会被 Git 同步带走。
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { s3TestConnection } from '../services/platform'
|
||||||
|
import { s3ConfigPayload, type S3Settings } from '../services/s3'
|
||||||
|
|
||||||
|
export function S3SettingsModal({ initial, onSave, onClose }: {
|
||||||
|
initial: S3Settings
|
||||||
|
onSave: (next: S3Settings) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<S3Settings>(initial)
|
||||||
|
const [testState, setTestState] = useState<{ kind: 'idle' | 'busy' | 'ok' | 'err'; text: string }>({ kind: 'idle', text: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const set = (patch: Partial<S3Settings>) => {
|
||||||
|
setDraft((d) => ({ ...d, ...patch }))
|
||||||
|
setTestState({ kind: 'idle', text: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
setTestState({ kind: 'busy', text: '正在连接…' })
|
||||||
|
try {
|
||||||
|
const msg = await s3TestConnection(s3ConfigPayload(draft))
|
||||||
|
setTestState({ kind: 'ok', text: msg })
|
||||||
|
} catch (e) {
|
||||||
|
setTestState({ kind: 'err', text: e instanceof Error ? e.message : String(e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>S3 图片上传设置</h3>
|
||||||
|
<p className="modal-note">
|
||||||
|
图片上传到你自己的 S3 兼容存储(AWS S3、MinIO、Cloudflare R2、阿里云 OSS 等),
|
||||||
|
文档里的图片引用随之换成公开访问地址。密钥只保存在本机设置里,不会写进文档。
|
||||||
|
</p>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>服务端点(Endpoint,可空 = AWS S3)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.endpoint}
|
||||||
|
placeholder="https://s3.example.com"
|
||||||
|
onChange={(e) => set({ endpoint: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>区域(Region)*</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.region}
|
||||||
|
placeholder="us-east-1"
|
||||||
|
onChange={(e) => set({ region: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>存储桶(Bucket)*</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.bucket}
|
||||||
|
placeholder="my-images"
|
||||||
|
onChange={(e) => set({ bucket: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>Access Key *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.accessKey}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => set({ accessKey: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>Secret Key *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={draft.secretKey}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => set({ secretKey: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>路径前缀(可空,对象键 = 前缀 + 文件名)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.prefix}
|
||||||
|
placeholder="notes/"
|
||||||
|
onChange={(e) => set({ prefix: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>自定义访问域名(可空,如 CDN 域名;回填文档用它拼地址)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.publicBase}
|
||||||
|
placeholder="https://cdn.example.com"
|
||||||
|
onChange={(e) => set({ publicBase: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.autoUpload}
|
||||||
|
onChange={(e) => set({ autoUpload: e.target.checked })}
|
||||||
|
/>
|
||||||
|
自动上传图片(粘贴/插入的图片落盘后自动上传,并换成 S3 地址)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{testState.kind !== 'idle' && (
|
||||||
|
<p className="modal-note" style={testState.kind === 'err' ? { color: 'var(--danger, #c0392b)' } : undefined}>
|
||||||
|
{testState.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" onClick={() => void test()} disabled={testState.kind === 'busy'}>测试连接</button>
|
||||||
|
<button type="button" onClick={onClose}>取消</button>
|
||||||
|
<button type="button" className="primary" onClick={() => onSave(draft)}>保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ interface Props {
|
|||||||
sourceMode: boolean
|
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>
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,3 +348,25 @@ 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
|
||||||
|
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
|
||||||
|
return invoke<string>('s3_test_connection', { config })
|
||||||
|
}
|
||||||
|
|||||||
76
src/services/s3.ts
Normal file
76
src/services/s3.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// 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)/data/blob 已经是网络或内嵌资源,不需要上传。 */
|
||||||
|
export function isLocalImageSrc(src: string): boolean {
|
||||||
|
const t = src.trim()
|
||||||
|
if (!t) return false
|
||||||
|
return !/^(https?:|data:|blob:)/i.test(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
|
||||||
|
export function imageFileName(src: string): string {
|
||||||
|
const clean = safeDecode(src).replace(/\\/g, '/')
|
||||||
|
const name = clean.split('/').pop() ?? ''
|
||||||
|
return name || 'image.png'
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
53
tests/editor-images.test.ts
Normal file
53
tests/editor-images.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// 编辑器图片节点操作(docs/30):收集 src、按旧 src 回换、按 DOM 元素反查 src。
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc, imageSrcAtElement } from '../src/editor/editor'
|
||||||
|
|
||||||
|
const DOC = [
|
||||||
|
'# 图',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
async function makeEditor() {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const editor = createMarkdownEditor(root, DOC)
|
||||||
|
await editor.create()
|
||||||
|
return { root, editor }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('editor image ops', () => {
|
||||||
|
it('收集全部图片 src 并去重', async () => {
|
||||||
|
const { root, editor } = await makeEditor()
|
||||||
|
expect(collectImageSrcs(editor)).toEqual(['doc.assets/a.png', 'https://cdn.example.com/b.png'])
|
||||||
|
await editor.destroy()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按旧 src 回换全部同名图片节点(含重复引用)', async () => {
|
||||||
|
const { root, editor } = await makeEditor()
|
||||||
|
const n = replaceImageSrc(editor, 'doc.assets/a.png', 'https://s3.example.com/a.png')
|
||||||
|
expect(n).toBe(2)
|
||||||
|
const md = getMarkdown(editor)
|
||||||
|
expect(md).toContain('https://s3.example.com/a.png')
|
||||||
|
expect(md).not.toContain('doc.assets/a.png')
|
||||||
|
expect(collectImageSrcs(editor)).toEqual(['https://s3.example.com/a.png', 'https://cdn.example.com/b.png'])
|
||||||
|
await editor.destroy()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按 <img> 元素反查节点 src;非图片元素返回 null', async () => {
|
||||||
|
const { root, editor } = await makeEditor()
|
||||||
|
const img = root.querySelector('img')
|
||||||
|
expect(img).not.toBeNull()
|
||||||
|
expect(imageSrcAtElement(editor, img as HTMLElement)).toBe('doc.assets/a.png')
|
||||||
|
expect(imageSrcAtElement(editor, root)).toBeNull()
|
||||||
|
await editor.destroy()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
49
tests/s3-modal.test.tsx
Normal file
49
tests/s3-modal.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// S3 设置弹窗(docs/30):字段回填、编辑后保存的载荷、自动上传开关。
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { act } from 'react-dom/test-utils'
|
||||||
|
import { S3SettingsModal } from '../src/components/S3SettingsModal'
|
||||||
|
import { DEFAULT_S3_SETTINGS } from '../src/services/s3'
|
||||||
|
|
||||||
|
function setInput(input: HTMLInputElement, value: string) {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||||
|
setter?.call(input, value)
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('S3SettingsModal', () => {
|
||||||
|
it('填写桶/区域/密钥与自动上传开关后,保存回传完整设置', async () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const root = createRoot(container)
|
||||||
|
const onSave = vi.fn()
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<S3SettingsModal initial={DEFAULT_S3_SETTINGS} onSave={onSave} onClose={() => undefined} />)
|
||||||
|
})
|
||||||
|
const inputs = [...container.querySelectorAll('input')]
|
||||||
|
const byPlaceholder = (ph: string) => inputs.find((i) => i.placeholder === ph) as HTMLInputElement
|
||||||
|
await act(async () => {
|
||||||
|
setInput(byPlaceholder('us-east-1'), 'ap-northeast-1')
|
||||||
|
setInput(byPlaceholder('my-images'), 'my-images')
|
||||||
|
setInput(byPlaceholder('notes/'), 'blog/')
|
||||||
|
})
|
||||||
|
const textInputs = inputs.filter((i) => i.type === 'text' || i.type === 'password')
|
||||||
|
// Access Key / Secret Key 是无 placeholder 的两个输入(第 4、5 个字段)
|
||||||
|
await act(async () => {
|
||||||
|
setInput(textInputs[3], 'AKID')
|
||||||
|
setInput(textInputs[4], 'SECRET')
|
||||||
|
})
|
||||||
|
const checkbox = inputs.find((i) => i.type === 'checkbox') as HTMLInputElement
|
||||||
|
await act(async () => { checkbox.click() })
|
||||||
|
const saveBtn = [...container.querySelectorAll('button')].find((b) => b.textContent === '保存') as HTMLButtonElement
|
||||||
|
await act(async () => { saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1)
|
||||||
|
const saved = onSave.mock.calls[0][0]
|
||||||
|
expect(saved).toMatchObject({
|
||||||
|
region: 'ap-northeast-1', bucket: 'my-images', accessKey: 'AKID', secretKey: 'SECRET',
|
||||||
|
prefix: 'blog/', autoUpload: true,
|
||||||
|
})
|
||||||
|
await act(async () => { root.unmount() })
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
41
tests/s3.test.ts
Normal file
41
tests/s3.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { isLocalImageSrc, isS3Configured, resolveDocRelativePath, imageFileName, 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('isS3Configured', () => {
|
||||||
|
it('四项必填齐才算配置过', () => {
|
||||||
|
expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false)
|
||||||
|
expect(isS3Configured({ ...DEFAULT_S3_SETTINGS, bucket: 'b', region: 'r', accessKey: 'a', secretKey: 's' })).toBe(true)
|
||||||
|
expect(isS3Configured({ ...DEFAULT_S3_SETTINGS, bucket: 'b', region: 'r', accessKey: 'a' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveDocRelativePath', () => {
|
||||||
|
it('文档同目录 .assets 拼接(含 Windows 路径与 ..)', () => {
|
||||||
|
expect(resolveDocRelativePath('D:/notes/todo.md', 'todo.assets/a.png')).toBe('D:/notes/todo.assets/a.png')
|
||||||
|
expect(resolveDocRelativePath('D:\\notes\\todo.md', 'todo.assets\\a.png')).toBe('D:/notes/todo.assets/a.png')
|
||||||
|
expect(resolveDocRelativePath('/home/u/notes/todo.md', '../shared/a.png')).toBe('/home/u/shared/a.png')
|
||||||
|
expect(resolveDocRelativePath('/home/u/notes/todo.md', 'a%20b.png')).toBe('/home/u/notes/a b.png')
|
||||||
|
expect(resolveDocRelativePath('/home/u/notes/todo.md', 'C:/abs/a.png')).toBe('C:/abs/a.png')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('imageFileName', () => {
|
||||||
|
it('取文件名(含解码与反斜杠)', () => {
|
||||||
|
expect(imageFileName('doc.assets/图 1.png')).toBe('图 1.png')
|
||||||
|
expect(imageFileName('a%20b.png')).toBe('a b.png')
|
||||||
|
expect(imageFileName('x\\y\\z.jpg')).toBe('z.jpg')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user