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、构建通过。
This commit is contained in:
@@ -24,8 +24,14 @@
|
|||||||
endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload;
|
endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload;
|
||||||
加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。
|
加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。
|
||||||
弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。
|
弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。
|
||||||
- **「未上传」判定**:图片 src 不是 http(s)/data/blob 即本地引用
|
- **「未上传」判定**:本地文件引用(相对路径)或内嵌 base64 图片
|
||||||
(`isLocalImageSrc`)。上传成功后把编辑器里所有同 src 的图片节点
|
(`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`,同图多处引用一并换),文档
|
一次事务换成 S3 地址(`replaceImageSrc`,同图多处引用一并换),文档
|
||||||
随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉
|
随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉
|
||||||
离线打开时的图,且用户可能还要本地备份)。
|
离线打开时的图,且用户可能还要本地备份)。
|
||||||
@@ -51,6 +57,9 @@
|
|||||||
20150830T123600Z)——算出的 Signature 与官方值
|
20150830T123600Z)——算出的 Signature 与官方值
|
||||||
`5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7`
|
`5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7`
|
||||||
逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。
|
逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。
|
||||||
|
- **base64 通路**:同一独立 crate 里实测 upload_base64:解码后的字节
|
||||||
|
与原文一致地 PUT 到模拟 S3、Content-Type 按扩展名、非法 base64 在
|
||||||
|
发请求前就报错。
|
||||||
- **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地
|
- **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地
|
||||||
TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、
|
TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、
|
||||||
x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、
|
x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -2029,6 +2029,7 @@ dependencies = [
|
|||||||
name = "new-markdown"
|
name = "new-markdown"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"hmac",
|
"hmac",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ trash = "5"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -941,6 +941,17 @@ pub async fn s3_upload_image(
|
|||||||
.map_err(|e| format!("上传任务失败:{e}"))?
|
.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]
|
#[tauri::command]
|
||||||
pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
|
pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))
|
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ pub fn run() {
|
|||||||
commands::git_set_remote,
|
commands::git_set_remote,
|
||||||
commands::git_sync_now,
|
commands::git_sync_now,
|
||||||
commands::s3_upload_image,
|
commands::s3_upload_image,
|
||||||
|
commands::s3_upload_base64,
|
||||||
commands::s3_test_connection,
|
commands::s3_test_connection,
|
||||||
commands::delete_to_trash,
|
commands::delete_to_trash,
|
||||||
commands::db_recent_list,
|
commands::db_recent_list,
|
||||||
|
|||||||
@@ -161,6 +161,25 @@ pub fn upload(cfg: &S3Config, file_path: &str, file_name: &str) -> Result<String
|
|||||||
return Err("图片超过 64MB,未上传。".into());
|
return Err("图片超过 64MB,未上传。".into());
|
||||||
}
|
}
|
||||||
let bytes = std::fs::read(file_path).map_err(|e| format!("读取待上传图片失败:{e}"))?;
|
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 key = object_key(cfg, file_name);
|
||||||
let (url, host, canonical_uri) = target(cfg, &key);
|
let (url, host, canonical_uri) = target(cfg, &key);
|
||||||
let payload_hash = sha256_hex(&bytes);
|
let payload_hash = sha256_hex(&bytes);
|
||||||
|
|||||||
56
src/App.tsx
56
src/App.tsx
@@ -29,7 +29,7 @@ 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, s3UploadImage, 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'
|
||||||
@@ -37,7 +37,7 @@ 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 { S3SettingsModal } from './components/S3SettingsModal'
|
||||||
import { DEFAULT_S3_SETTINGS, isS3Configured, isLocalImageSrc, resolveDocRelativePath, imageFileName, s3ConfigPayload, type S3Settings } from './services/s3'
|
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'
|
||||||
@@ -1175,47 +1175,58 @@ export default function App() {
|
|||||||
const [s3Progress, setS3Progress] = useState<{ total: number; done: number; current: string; errors: string[]; finished: boolean } | null>(null)
|
const [s3Progress, setS3Progress] = useState<{ total: number; done: number; current: string; errors: string[]; finished: boolean } | null>(null)
|
||||||
const s3BusyRef = useRef(false)
|
const s3BusyRef = useRef(false)
|
||||||
|
|
||||||
/** 上传一张本地图片(docPath + 文档相对 src),返回 S3 公开地址。 */
|
/**
|
||||||
const uploadLocalImage = useCallback(async (docPath: string, src: string): Promise<string> => {
|
* 上传一张未上传图片,返回 S3 公开地址:base64 内嵌图直接传字节(不需
|
||||||
const abs = resolveDocRelativePath(docPath, src)
|
* 文档落盘);本地文件图按 docPath + 相对 src 定位文件。
|
||||||
return s3UploadImage(s3ConfigPayload(uiRef.current.s3), abs, imageFileName(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 地址。 */
|
/** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */
|
||||||
const runSingleImageUpload = useCallback(async (src: string) => {
|
const runSingleImageUpload = useCallback(async (src: string) => {
|
||||||
const current = activeTab(stateRef.current)
|
const current = activeTab(stateRef.current)
|
||||||
if (!current?.path) { setError('请先保存文档,再上传图片。'); return }
|
if (!current) return
|
||||||
|
if (!isDataImageSrc(src) && !current.path) { setError('请先保存文档,再上传本地图片。'); return }
|
||||||
if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
|
if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
|
||||||
flash('正在上传图片…')
|
flash('正在上传图片…')
|
||||||
try {
|
try {
|
||||||
const url = await uploadLocalImage(current.path, src)
|
const url = await uploadImageSrc(current.path, src)
|
||||||
paneApisRef.current.get(current.id)?.replaceImageSrc(src, url)
|
paneApisRef.current.get(current.id)?.replaceImageSrc(src, url)
|
||||||
flash('图片已上传到 S3,引用已更新')
|
flash('图片已上传到 S3,引用已更新')
|
||||||
} catch (e) { setError(e instanceof Error ? e.message : String(e)) }
|
} catch (e) { setError(e instanceof Error ? e.message : String(e)) }
|
||||||
}, [flash, uploadLocalImage])
|
}, [flash, uploadImageSrc])
|
||||||
|
|
||||||
/** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */
|
/** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */
|
||||||
const runBatchImageUpload = async () => {
|
const runBatchImageUpload = async () => {
|
||||||
if (!tab?.path) { setError('请先保存文档,再批量上传图片。'); return }
|
if (!tab) return
|
||||||
if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return }
|
if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return }
|
||||||
if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
|
if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
|
||||||
if (s3BusyRef.current) return
|
if (s3BusyRef.current) return
|
||||||
const api = paneApisRef.current.get(tab.id)
|
const api = paneApisRef.current.get(tab.id)
|
||||||
if (!api) return
|
if (!api) return
|
||||||
const locals = [...new Set(api.listImages().filter(isLocalImageSrc))]
|
const locals = [...new Set(api.listImages().filter(isUploadableImageSrc))]
|
||||||
if (locals.length === 0) { flash('本文档没有未上传的本地图片'); return }
|
if (locals.length === 0) { flash('本文档没有未上传的图片'); return }
|
||||||
const docPath = tab.path
|
const docPath = tab.path
|
||||||
s3BusyRef.current = true
|
s3BusyRef.current = true
|
||||||
setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false })
|
setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false })
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
let done = 0
|
let done = 0
|
||||||
for (const src of locals) {
|
for (const src of locals) {
|
||||||
setS3Progress((pr) => (pr ? { ...pr, current: imageFileName(src) } : pr))
|
setS3Progress((pr) => (pr ? { ...pr, current: imageDisplayName(src) } : pr))
|
||||||
try {
|
try {
|
||||||
const url = await uploadLocalImage(docPath, src)
|
const url = await uploadImageSrc(docPath, src)
|
||||||
api.replaceImageSrc(src, url)
|
api.replaceImageSrc(src, url)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.push(`${imageFileName(src)}:${e instanceof Error ? e.message : String(e)}`)
|
errors.push(`${imageDisplayName(src)}:${e instanceof Error ? e.message : String(e)}`)
|
||||||
}
|
}
|
||||||
done += 1
|
done += 1
|
||||||
setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr))
|
setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr))
|
||||||
@@ -1229,12 +1240,12 @@ export default function App() {
|
|||||||
* 粘贴建节点是异步的,第一次替换可能扑空,900ms 后重试一次;仍扑空则
|
* 粘贴建节点是异步的,第一次替换可能扑空,900ms 后重试一次;仍扑空则
|
||||||
* 保留本地引用并提示可用批量上传同步,不静默。
|
* 保留本地引用并提示可用批量上传同步,不静默。
|
||||||
*/
|
*/
|
||||||
const autoUploadSwap = useCallback((tabId: string, docPath: string, relSrc: string) => {
|
const autoUploadSwap = useCallback((tabId: string, docPath: string | null, relSrc: string) => {
|
||||||
const settings = uiRef.current.s3
|
const settings = uiRef.current.s3
|
||||||
if (!settings.autoUpload || !isS3Configured(settings)) return
|
if (!isTauri() || !settings.autoUpload || !isS3Configured(settings)) return
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const url = await s3UploadImage(s3ConfigPayload(settings), resolveDocRelativePath(docPath, relSrc), imageFileName(relSrc))
|
const url = await uploadImageSrc(docPath, relSrc)
|
||||||
let n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0
|
let n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0
|
||||||
if (n === 0) {
|
if (n === 0) {
|
||||||
await new Promise((r) => setTimeout(r, 900))
|
await new Promise((r) => setTimeout(r, 900))
|
||||||
@@ -1245,7 +1256,7 @@ export default function App() {
|
|||||||
flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`)
|
flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [flash])
|
}, [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> => {
|
||||||
@@ -1257,12 +1268,15 @@ export default function App() {
|
|||||||
autoUploadSwap(current.id, current.path, rel)
|
autoUploadSwap(current.id, current.path, rel)
|
||||||
return 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])
|
}, [autoUploadSwap])
|
||||||
|
|
||||||
const insertImage = useCallback(async () => {
|
const insertImage = useCallback(async () => {
|
||||||
@@ -1508,7 +1522,7 @@ export default function App() {
|
|||||||
const found = new Set<string>()
|
const found = new Set<string>()
|
||||||
const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
|
const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
while ((m = re.exec(tab.content))) if (isLocalImageSrc(m[1])) found.add(m[1])
|
while ((m = re.exec(tab.content))) if (isUploadableImageSrc(m[1])) found.add(m[1])
|
||||||
return found.size
|
return found.size
|
||||||
}, [tab?.id, tab?.content, sourceMode])
|
}, [tab?.id, tab?.content, sourceMode])
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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'
|
import { isUploadableImageSrc } from '../services/s3'
|
||||||
|
|
||||||
export type EditorMenuAction =
|
export type EditorMenuAction =
|
||||||
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||||
@@ -29,7 +29,7 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
|
|||||||
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) }
|
||||||
const showUpload = Boolean(imageSrc && isLocalImageSrc(imageSrc))
|
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
|
||||||
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项)
|
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项)
|
||||||
const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0))
|
const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0))
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -366,6 +366,11 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f
|
|||||||
return invoke<string>('s3_upload_image', { config, filePath, fileName })
|
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 验连通与密钥,成功返回一句可读结论。 */
|
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
|
||||||
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
|
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
|
||||||
return invoke<string>('s3_test_connection', { config })
|
return invoke<string>('s3_test_connection', { config })
|
||||||
|
|||||||
@@ -23,13 +23,53 @@ export function isS3Configured(s: S3Settings): boolean {
|
|||||||
return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim())
|
return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本地图片 = 文档里的相对/本地引用;http(s)/data/blob 已经是网络或内嵌资源,不需要上传。 */
|
/** 本地图片 = 文档里的相对/本地文件引用;http(s)/blob 已经是网络资源,不需要上传。 */
|
||||||
export function isLocalImageSrc(src: string): boolean {
|
export function isLocalImageSrc(src: string): boolean {
|
||||||
const t = src.trim()
|
const t = src.trim()
|
||||||
if (!t) return false
|
if (!t) return false
|
||||||
return !/^(https?:|data:|blob:)/i.test(t)
|
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)。 */
|
/** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */
|
||||||
export function s3ConfigPayload(s: S3Settings): S3ConfigPayload {
|
export function s3ConfigPayload(s: S3Settings): S3ConfigPayload {
|
||||||
return {
|
return {
|
||||||
@@ -68,6 +108,11 @@ function safeDecode(s: string): string {
|
|||||||
try { return decodeURIComponent(s) } catch { return s }
|
try { return decodeURIComponent(s) } catch { return s }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 进度/报错里展示的图片名:base64 图用其对象名(哈希名),文件图用原文件名。 */
|
||||||
|
export function imageDisplayName(src: string): string {
|
||||||
|
return dataImageFileName(src) ?? imageFileName(src)
|
||||||
|
}
|
||||||
|
|
||||||
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
|
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
|
||||||
export function imageFileName(src: string): string {
|
export function imageFileName(src: string): string {
|
||||||
const clean = safeDecode(src).replace(/\\/g, '/')
|
const clean = safeDecode(src).replace(/\\/g, '/')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。
|
// S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { isLocalImageSrc, isS3Configured, resolveDocRelativePath, imageFileName, DEFAULT_S3_SETTINGS } from '../src/services/s3'
|
import { isLocalImageSrc, isS3Configured, resolveDocRelativePath, imageFileName, isUploadableImageSrc, parseDataImage, dataImageFileName, DEFAULT_S3_SETTINGS } from '../src/services/s3'
|
||||||
|
|
||||||
describe('isLocalImageSrc', () => {
|
describe('isLocalImageSrc', () => {
|
||||||
it('相对路径算本地;http/data/blob 不算', () => {
|
it('相对路径算本地;http/data/blob 不算', () => {
|
||||||
@@ -14,6 +14,29 @@ describe('isLocalImageSrc', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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', () => {
|
describe('isS3Configured', () => {
|
||||||
it('四项必填齐才算配置过', () => {
|
it('四项必填齐才算配置过', () => {
|
||||||
expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false)
|
expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user