feat(image): 图片直接显示 + 右键复制图片; 修未读角标不清
## 图片直显 + 右键复制 以前图片消息基本只能看到 "[图片] xxx.png" 的文件卡片, 而且右键没有"复制"。 根因有两层: 1. `asImage` 从来没被设成 true —— 用"发送文件"选中的图片是当**普通文件**发的, 对方只能看到文件卡片。 2. 拖拽/粘贴的图走 `sendBuffer`, 只在 <200KB 时才带 `thumbDataUrl`, 大图没有缩略图 -> 渲染层 fallback 成文件卡片。 改法: - 缩略图随消息体走 (`body.thumbDataUrl`): **发送方生成一次, 对方直接拿来显示**, 对端不用再解码, 老版本对端也看得到图。 - 缩略图用 Electron 的 `nativeImage` 生成 (`src/main/image.ts`), 不引 sharp/jimp: 宽度上限 480, **PNG 优先 (保透明 —— 截图转 JPEG 会变黑底)**, PNG 超 60KB 才退 JPEG, 然后按"降质量 -> 缩尺寸"的阶梯找一档够小的。 阈值/编码选择/选图源抽到 `src/main/image-core.ts` (纯逻辑, 不 import electron, 可单测)。 源文件 >20MB 不生成 —— nativeImage 解码会把整张图铺成 RGBA 内存。 - 拖拽/选中的图片改走 `sendFile(asImage: true)` (有真实路径, 流式上传), 只有粘贴的图片才走 `sendBuffer` (整图 base64 进 WS 帧, 大图撑爆带宽)。 - 右键"复制图片" -> `image:copy` IPC -> `clipboard.writeImage`, 写的是**原图** (本地文件优先, 退回 dataBase64), 不是气泡里那个缩略图。 - 缩略图缺失时 (老消息/老版本对端) 渲染层退到 `image:thumb` 现场生成, 失败才显示文件卡片。点开看大图用 `image:dataUrl` 读本地原图。 ## 修未读角标不清 用户正看着某个会话, dock/任务栏角标的数字却不消。 根因: `App.vue` 的 `message:received` 里先判 `if (!savedPath) return` (等文件落地再算未读), 而**文字消息没有 savedPath** —— 正好在"清未读"之前就 return 了。 文件/图片消息因为第二帧带 savedPath 才碰巧能清掉, 所以只有文字消息暴露这个 bug。 改法: - 决策抽成纯函数 `src/renderer/src/utils/unread.ts` 的 `unreadActionForIncoming()` (`clear` / `wait` / `count`): **先判 isActive -> 清未读 + 回已读回执, 再判 savedPath**。 - 侧边栏**重复点同一个设备**时 activePeerId 不变, watch 不触发 -> 显式清一次。 - 主进程 `device:delete` 清了 unread 但忘了 `updateBadge()` -> 角标残留, 补上。 ## 验证 - npm run typecheck ✅ - npm run build ✅ - npm run selftest ✅ 76 通过 / 0 失败 (新增 I1-I8 图片 + U1-U4 角标) - npm run selftest:sync ✅ 全部通过 - npm run selftest:image ✅ 真 Electron 下验 nativeImage (新增命令) - npm run package:linux ✅ 新 deb - npm run selftest:package ✅ 4/4 真机 (CDP 驱动真实窗口) 验证过: 图片气泡内联显示 (640x420, fallback 0 个), 右键菜单含"复制图片", 点下去系统剪贴板里是 **640x420 原图** (不是缩略图)。
This commit is contained in:
+54
-2
@@ -31,6 +31,8 @@ import { ForwardManager } from './remote/forward'
|
||||
import { UsbBridgeManager } from './remote/usb'
|
||||
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
|
||||
import { computeWsState } from './ws-state'
|
||||
import { makeThumb, makeThumbFromFile, makeThumbFromBuffer, copyToClipboard, readImageDataUrl } from './image'
|
||||
import type { ImageSource } from './image-core'
|
||||
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig, WsFrame } from './protocol'
|
||||
import { DEFAULT_PORTS } from './protocol'
|
||||
|
||||
@@ -609,8 +611,9 @@ export function registerIpc() {
|
||||
ctx.chatClient.remove(deviceId) // 停掉 backoff 重连
|
||||
ctx.discovery.forget(deviceId) // 从 in-memory peers Map 移除
|
||||
broadcastToRenderer('device:lost', { deviceId }) // 通知 renderer 把它从 device store 摘掉
|
||||
// 清掉对应未读
|
||||
// 清掉对应未读 (顺手刷一次任务栏角标 —— 少了这步, 删了设备角标数字还挂着)
|
||||
clearUnread(deviceId)
|
||||
updateBadge()
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -704,6 +707,10 @@ export function registerIpc() {
|
||||
const messageId = randomUUID()
|
||||
const peerInfo: any = peer
|
||||
|
||||
// 图片: 现场生成缩略图塞进消息体 —— 发送方生成一次, 对方直接拿来显示,
|
||||
// 不用在对端再解码一遍 (也顺带让"老版本对端"能显示)。
|
||||
const thumbDataUrl = args.asImage ? makeThumbFromFile(args.localPath) : undefined
|
||||
|
||||
// 1. 构造初始 envelope (savedPath=null, hash=null) - 气泡先占位, 进度条 0%
|
||||
const baseBody = {
|
||||
type: args.asImage ? 'image' : 'file',
|
||||
@@ -713,6 +720,7 @@ export function registerIpc() {
|
||||
savedPath: null as string | null,
|
||||
hash: null as string | null,
|
||||
localPath: args.localPath, // retry 时复用
|
||||
...(thumbDataUrl ? { thumbDataUrl } : {}),
|
||||
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||
}
|
||||
const env: MessageEnvelope = {
|
||||
@@ -815,12 +823,18 @@ export function registerIpc() {
|
||||
const fileId = randomUUID()
|
||||
const messageId = randomUUID()
|
||||
|
||||
// 缩略图: 小图直接用原字节 (省一次重编码), 大图现生成缩略图 ——
|
||||
// 以前大图不带缩略图, 气泡只能显示成"[图片] xxx.png"的文件卡片。
|
||||
const thumbDataUrl = buf.length < 200_000
|
||||
? `data:${args.mime};base64,${args.dataBase64}`
|
||||
: makeThumbFromBuffer(buf)
|
||||
|
||||
const baseBody: ImageMessage = {
|
||||
type: 'image',
|
||||
messageId, ts: Date.now(),
|
||||
fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
||||
fileId, name: args.name, size: buf.length, mime: args.mime,
|
||||
thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
|
||||
thumbDataUrl,
|
||||
dataBase64: args.dataBase64,
|
||||
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||
}
|
||||
@@ -1109,6 +1123,44 @@ export function registerIpc() {
|
||||
return { path: p, name: basename(p), size: st.size }
|
||||
})
|
||||
|
||||
// ===== 图片: 缩略图 / 复制 / 看原图 =====
|
||||
//
|
||||
// 消息体里的 thumbDataUrl 是"发送方生成、双方共用"的缩略图。
|
||||
// 但老消息 (落库时还没有缩略图) 和老版本对端发来的消息都没有它 ——
|
||||
// 这时渲染层会退到 image:thumb 现场生成。
|
||||
|
||||
// 现场生成缩略图 (渲染层懒加载兜底)
|
||||
ipcMain.handle('image:thumb', async (_e, args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => {
|
||||
try {
|
||||
const src: ImageSource = args.path
|
||||
? { kind: 'file', path: args.path, mime: args.mime, dataUrl: args.dataUrl, base64: args.base64 }
|
||||
: { kind: 'dataUrl', dataUrl: args.dataUrl, mime: args.mime, base64: args.base64 }
|
||||
const dataUrl = makeThumb(src)
|
||||
if (!dataUrl) return { ok: false, reason: '无法生成缩略图' }
|
||||
return { ok: true, dataUrl }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 右键"复制图片" —— 写的是原图, 不是缩略图
|
||||
ipcMain.handle('image:copy', async (_e, args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => {
|
||||
try {
|
||||
const src: ImageSource = args.path
|
||||
? { kind: 'file', path: args.path, mime: args.mime, dataUrl: args.dataUrl, base64: args.base64 }
|
||||
: { kind: 'dataUrl', dataUrl: args.dataUrl, mime: args.mime, base64: args.base64 }
|
||||
return copyToClipboard(src)
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 点开看大图 —— 读本地原图成 data URL (气泡里是缩略图)
|
||||
ipcMain.handle('image:dataUrl', async (_e, args: { path?: string }) => {
|
||||
if (!args?.path) return { ok: false, reason: '没有本地文件' }
|
||||
return readImageDataUrl(args.path)
|
||||
})
|
||||
|
||||
// ===== 网盘 (浏览对方共享目录 + 选文件下载) =====
|
||||
|
||||
// 找 peer: 优先用 discovery 的在线列表, 退回 DB 里的历史记录
|
||||
|
||||
Reference in New Issue
Block a user