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:
56
src/App.tsx
56
src/App.tsx
@@ -29,7 +29,7 @@ import {
|
||||
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
||||
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
||||
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
||||
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, type GitStatusInfo,
|
||||
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo,
|
||||
} from './services/platform'
|
||||
import * as store from './services/store'
|
||||
import { recoveryKey, planRecovery } from './utils/recovery'
|
||||
@@ -37,7 +37,7 @@ import { appWindowClaims, appWindowId } from './services/windowClaims'
|
||||
import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync'
|
||||
import { GitSyncModal } from './components/GitSyncModal'
|
||||
import { S3SettingsModal } from './components/S3SettingsModal'
|
||||
import { DEFAULT_S3_SETTINGS, isS3Configured, 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 { AppearanceModal, ExportModal } from './components/Stage5Modals'
|
||||
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 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 公开地址:base64 内嵌图直接传字节(不需
|
||||
* 文档落盘);本地文件图按 docPath + 相对 src 定位文件。
|
||||
*/
|
||||
const uploadImageSrc = useCallback(async (docPath: string | null, src: string): Promise<string> => {
|
||||
const cfg = s3ConfigPayload(uiRef.current.s3)
|
||||
const data = parseDataImage(src)
|
||||
if (data) {
|
||||
const name = dataImageFileName(src)
|
||||
if (!name) throw new Error('无法识别内嵌图片格式')
|
||||
return s3UploadBase64(cfg, data.base64, name)
|
||||
}
|
||||
if (!docPath) throw new Error('文档尚未保存,无法定位本地图片文件')
|
||||
return s3UploadImage(cfg, resolveDocRelativePath(docPath, src), imageFileName(src))
|
||||
}, [])
|
||||
|
||||
/** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */
|
||||
const runSingleImageUpload = useCallback(async (src: string) => {
|
||||
const current = activeTab(stateRef.current)
|
||||
if (!current?.path) { setError('请先保存文档,再上传图片。'); return }
|
||||
if (!current) return
|
||||
if (!isDataImageSrc(src) && !current.path) { setError('请先保存文档,再上传本地图片。'); return }
|
||||
if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
|
||||
flash('正在上传图片…')
|
||||
try {
|
||||
const url = await uploadLocalImage(current.path, src)
|
||||
const url = await uploadImageSrc(current.path, src)
|
||||
paneApisRef.current.get(current.id)?.replaceImageSrc(src, url)
|
||||
flash('图片已上传到 S3,引用已更新')
|
||||
} catch (e) { setError(e instanceof Error ? e.message : String(e)) }
|
||||
}, [flash, uploadLocalImage])
|
||||
}, [flash, uploadImageSrc])
|
||||
|
||||
/** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */
|
||||
const runBatchImageUpload = async () => {
|
||||
if (!tab?.path) { setError('请先保存文档,再批量上传图片。'); return }
|
||||
if (!tab) return
|
||||
if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return }
|
||||
if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
|
||||
if (s3BusyRef.current) return
|
||||
const api = paneApisRef.current.get(tab.id)
|
||||
if (!api) return
|
||||
const locals = [...new Set(api.listImages().filter(isLocalImageSrc))]
|
||||
if (locals.length === 0) { flash('本文档没有未上传的本地图片'); return }
|
||||
const locals = [...new Set(api.listImages().filter(isUploadableImageSrc))]
|
||||
if (locals.length === 0) { flash('本文档没有未上传的图片'); return }
|
||||
const docPath = tab.path
|
||||
s3BusyRef.current = true
|
||||
setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false })
|
||||
const errors: string[] = []
|
||||
let done = 0
|
||||
for (const src of locals) {
|
||||
setS3Progress((pr) => (pr ? { ...pr, current: imageFileName(src) } : pr))
|
||||
setS3Progress((pr) => (pr ? { ...pr, current: imageDisplayName(src) } : pr))
|
||||
try {
|
||||
const url = await uploadLocalImage(docPath, src)
|
||||
const url = await uploadImageSrc(docPath, src)
|
||||
api.replaceImageSrc(src, url)
|
||||
} 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
|
||||
setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr))
|
||||
@@ -1229,12 +1240,12 @@ export default function App() {
|
||||
* 粘贴建节点是异步的,第一次替换可能扑空,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
|
||||
if (!settings.autoUpload || !isS3Configured(settings)) return
|
||||
if (!isTauri() || !settings.autoUpload || !isS3Configured(settings)) return
|
||||
void (async () => {
|
||||
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
|
||||
if (n === 0) {
|
||||
await new Promise((r) => setTimeout(r, 900))
|
||||
@@ -1245,7 +1256,7 @@ export default function App() {
|
||||
flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
})()
|
||||
}, [flash])
|
||||
}, [flash, uploadImageSrc])
|
||||
|
||||
/** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */
|
||||
const handleImageFile = useCallback(async (file: File): Promise<string> => {
|
||||
@@ -1257,12 +1268,15 @@ export default function App() {
|
||||
autoUploadSwap(current.id, current.path, rel)
|
||||
return rel
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => resolve(URL.createObjectURL(file))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
// 未落盘文档的粘贴图是 base64 内嵌形态:开了自动上传同样传走并回换
|
||||
if (current && dataUrl.startsWith('data:')) autoUploadSwap(current.id, current.path ?? null, dataUrl)
|
||||
return dataUrl
|
||||
}, [autoUploadSwap])
|
||||
|
||||
const insertImage = useCallback(async () => {
|
||||
@@ -1508,7 +1522,7 @@ export default function App() {
|
||||
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])
|
||||
while ((m = re.exec(tab.content))) if (isUploadableImageSrc(m[1])) found.add(m[1])
|
||||
return found.size
|
||||
}, [tab?.id, tab?.content, sourceMode])
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef } from 'react'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
import { clampMenuPosition } from '../utils/menuPos'
|
||||
import { isLocalImageSrc } from '../services/s3'
|
||||
import { isUploadableImageSrc } from '../services/s3'
|
||||
|
||||
export type EditorMenuAction =
|
||||
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||
@@ -29,7 +29,7 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useDismiss(ref, true, onClose)
|
||||
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
|
||||
const showUpload = Boolean(imageSrc && isLocalImageSrc(imageSrc))
|
||||
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
|
||||
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项)
|
||||
const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0))
|
||||
return (
|
||||
|
||||
@@ -366,6 +366,11 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f
|
||||
return invoke<string>('s3_upload_image', { config, filePath, fileName })
|
||||
}
|
||||
|
||||
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
|
||||
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
|
||||
return invoke<string>('s3_upload_base64', { config, base64, fileName })
|
||||
}
|
||||
|
||||
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
|
||||
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
|
||||
return invoke<string>('s3_test_connection', { config })
|
||||
|
||||
@@ -23,13 +23,53 @@ export function isS3Configured(s: S3Settings): boolean {
|
||||
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 {
|
||||
const t = src.trim()
|
||||
if (!t) return false
|
||||
return !/^(https?:|data:|blob:)/i.test(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内嵌 base64 图片(data:image/...;base64,...):未落盘文档粘贴的图片、
|
||||
* 导入文档自带的图片都是这种形态——同样算「未上传」,上传时直接传字节,
|
||||
* 不需要本地文件(2026-11-15 用户指正:原先把 data: 一律排除在检测外)。
|
||||
*/
|
||||
export function parseDataImage(src: string): { mime: string; base64: string } | null {
|
||||
const m = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(src.trim())
|
||||
if (!m) return null
|
||||
return { mime: m[1].toLowerCase(), base64: m[2].replace(/\s+/g, '') }
|
||||
}
|
||||
|
||||
export function isDataImageSrc(src: string): boolean {
|
||||
return parseDataImage(src) !== null
|
||||
}
|
||||
|
||||
/** 需要上传的图片 = 本地文件引用 或 内嵌 base64 图片。 */
|
||||
export function isUploadableImageSrc(src: string): boolean {
|
||||
return isLocalImageSrc(src) || isDataImageSrc(src)
|
||||
}
|
||||
|
||||
/**
|
||||
* base64 图片的对象文件名:image-<内容哈希>.<扩展名>。
|
||||
* 用内容哈希(FNV-1a)命名:同一张图重复上传同名覆盖、幂等;不同图不撞名。
|
||||
*/
|
||||
export function dataImageFileName(src: string): string | null {
|
||||
const parsed = parseDataImage(src)
|
||||
if (!parsed) return null
|
||||
let ext = parsed.mime.split('/')[1] ?? 'png'
|
||||
ext = ext.split('+')[0]
|
||||
if (ext === 'jpeg') ext = 'jpg'
|
||||
if (!/^[a-z0-9]+$/.test(ext)) ext = 'png'
|
||||
let h = 0x811c9dc5
|
||||
const b64 = parsed.base64
|
||||
for (let i = 0; i < b64.length; i++) {
|
||||
h ^= b64.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193)
|
||||
}
|
||||
return `image-${(h >>> 0).toString(16).padStart(8, '0')}.${ext}`
|
||||
}
|
||||
|
||||
/** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */
|
||||
export function s3ConfigPayload(s: S3Settings): S3ConfigPayload {
|
||||
return {
|
||||
@@ -68,6 +108,11 @@ function safeDecode(s: string): string {
|
||||
try { return decodeURIComponent(s) } catch { return s }
|
||||
}
|
||||
|
||||
/** 进度/报错里展示的图片名:base64 图用其对象名(哈希名),文件图用原文件名。 */
|
||||
export function imageDisplayName(src: string): string {
|
||||
return dataImageFileName(src) ?? imageFileName(src)
|
||||
}
|
||||
|
||||
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
|
||||
export function imageFileName(src: string): string {
|
||||
const clean = safeDecode(src).replace(/\\/g, '/')
|
||||
|
||||
Reference in New Issue
Block a user