diff --git a/electron/main.ts b/electron/main.ts index cace612..c4e8b79 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -370,6 +370,66 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe return win } +/** + * 创建独立的图片查看窗口 + */ +function createImageViewerWindow(imagePath: string) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + const win = new BrowserWindow({ + width: 900, + height: 700, + minWidth: 400, + minHeight: 300, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false // 允许加载本地文件 + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#00000000', + symbolColor: '#ffffff', + height: 40 + }, + show: false, + backgroundColor: '#000000', + autoHideMenuBar: true + }) + + win.once('ready-to-show', () => { + win.show() + }) + + const imageParam = `imagePath=${encodeURIComponent(imagePath)}` + + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/image-viewer-window?${imageParam}` + }) + } + + return win +} + /** * 创建独立的聊天记录窗口 */ @@ -941,6 +1001,11 @@ function registerIpcHandlers() { return true }) + // 打开图片查看窗口 + ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => { + createImageViewerWindow(imagePath) + }) + // 完成引导,关闭引导窗口并显示主窗口 ipcMain.handle('window:completeOnboarding', async () => { try { diff --git a/electron/preload.ts b/electron/preload.ts index 628e4cb..5836625 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -64,6 +64,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), resizeToFitVideo: (videoWidth: number, videoHeight: number) => ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), + openImageViewerWindow: (imagePath: string) => + ipcRenderer.invoke('window:openImageViewerWindow', imagePath), openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) }, diff --git a/src/App.tsx b/src/App.tsx index a5121c8..0cf6661 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' +import ImageWindow from './pages/ImageWindow' import SnsPage from './pages/SnsPage' import ContactsPage from './pages/ContactsPage' import ChatHistoryPage from './pages/ChatHistoryPage' @@ -318,6 +319,12 @@ function App() { return } + // 独立图片查看窗口 + const isImageViewerWindow = location.pathname === '/image-viewer-window' + if (isImageViewerWindow) { + return + } + // 独立聊天记录窗口 if (isChatHistoryWindow) { return diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index f5e99e2..1b17182 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -4,7 +4,6 @@ import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' -import { ImagePreview } from '../components/ImagePreview' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import JumpToDateDialog from '../components/JumpToDateDialog' @@ -1630,7 +1629,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false) const [voiceTranscriptError, setVoiceTranscriptError] = useState(false) const voiceTranscriptRequestedRef = useRef(false) - const [showImagePreview, setShowImagePreview] = useState(false) const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true) const [voiceCurrentTime, setVoiceCurrentTime] = useState(0) const [voiceDuration, setVoiceDuration] = useState(0) @@ -1996,11 +1994,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o }, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd]) useEffect(() => { - if (!isImage || !showImagePreview || !imageHasUpdate) return + if (!isImage || !imageHasUpdate) return if (imageAutoHdTriggered.current === imageCacheKey) return imageAutoHdTriggered.current = imageCacheKey triggerForceHd() - }, [isImage, showImagePreview, imageHasUpdate, imageCacheKey, triggerForceHd]) + }, [isImage, imageHasUpdate, imageCacheKey, triggerForceHd]) // 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清 useEffect(() => { @@ -2008,11 +2006,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o triggerForceHd() }, [isImage, imageInView, triggerForceHd]) - useEffect(() => { - if (!isImage || !showImagePreview) return - triggerForceHd() - }, [isImage, showImagePreview, triggerForceHd]) - useEffect(() => { if (!isVoice) return @@ -2363,15 +2356,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o if (imageHasUpdate) { void requestImageDecrypt(true, true) } - setShowImagePreview(true) + void window.electronAPI.window.openImageViewerWindow(imageLocalPath) }} onLoad={() => setImageError(false)} onError={() => setImageError(true)} /> - {showImagePreview && ( - setShowImagePreview(false)} /> - )} )} diff --git a/src/pages/ImageWindow.scss b/src/pages/ImageWindow.scss new file mode 100644 index 0000000..a1acf6c --- /dev/null +++ b/src/pages/ImageWindow.scss @@ -0,0 +1,99 @@ +.image-window-container { + width: 100vw; + height: 100vh; + background-color: var(--bg-primary); + display: flex; + flex-direction: column; + overflow: hidden; + user-select: none; + + .title-bar { + height: 40px; + min-height: 40px; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding-right: 140px; // 为原生窗口控件留出空间 + + .window-drag-area { + flex: 1; + height: 100%; + -webkit-app-region: drag; + } + + .title-bar-controls { + display: flex; + align-items: center; + gap: 8px; + -webkit-app-region: no-drag; + margin-right: 16px; + + button { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + } + + .scale-text { + min-width: 50px; + text-align: center; + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + .divider { + width: 1px; + height: 14px; + background: var(--border-color); + margin: 0 4px; + } + } + } + + .image-viewport { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + cursor: grab; + + &:active { + cursor: grabbing; + } + + img { + max-width: none; + max-height: none; + object-fit: contain; + will-change: transform; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + pointer-events: auto; + } + } +} + +.image-window-empty { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + background-color: var(--bg-primary); +} diff --git a/src/pages/ImageWindow.tsx b/src/pages/ImageWindow.tsx new file mode 100644 index 0000000..90ac068 --- /dev/null +++ b/src/pages/ImageWindow.tsx @@ -0,0 +1,162 @@ + +import { useState, useEffect, useRef, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' +import './ImageWindow.scss' + +export default function ImageWindow() { + const [searchParams] = useSearchParams() + const imagePath = searchParams.get('imagePath') + const [scale, setScale] = useState(1) + const [rotation, setRotation] = useState(0) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [initialScale, setInitialScale] = useState(1) + const viewportRef = useRef(null) + + // 使用 ref 存储拖动状态,避免闭包问题 + const dragStateRef = useRef({ + isDragging: false, + startX: 0, + startY: 0, + startPosX: 0, + startPosY: 0 + }) + + const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10)) + const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1)) + const handleRotate = () => setRotation(prev => (prev + 90) % 360) + const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360) + + // 重置视图 + const handleReset = useCallback(() => { + setScale(1) + setRotation(0) + setPosition({ x: 0, y: 0 }) + }, []) + + // 图片加载完成后计算初始缩放 + const handleImageLoad = useCallback((e: React.SyntheticEvent) => { + const img = e.currentTarget + const naturalWidth = img.naturalWidth + const naturalHeight = img.naturalHeight + + if (viewportRef.current) { + const viewportWidth = viewportRef.current.clientWidth * 0.9 + const viewportHeight = viewportRef.current.clientHeight * 0.9 + const scaleX = viewportWidth / naturalWidth + const scaleY = viewportHeight / naturalHeight + const fitScale = Math.min(scaleX, scaleY, 1) + setInitialScale(fitScale) + setScale(1) + } + }, []) + + // 使用原生事件监听器处理拖动 + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!dragStateRef.current.isDragging) return + + const dx = e.clientX - dragStateRef.current.startX + const dy = e.clientY - dragStateRef.current.startY + + setPosition({ + x: dragStateRef.current.startPosX + dx, + y: dragStateRef.current.startPosY + dy + }) + } + + const handleMouseUp = () => { + dragStateRef.current.isDragging = false + document.body.style.cursor = '' + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + }, []) + + const handleMouseDown = (e: React.MouseEvent) => { + if (e.button !== 0) return + e.preventDefault() + + dragStateRef.current = { + isDragging: true, + startX: e.clientX, + startY: e.clientY, + startPosX: position.x, + startPosY: position.y + } + document.body.style.cursor = 'grabbing' + } + + const handleWheel = useCallback((e: React.WheelEvent) => { + const delta = -Math.sign(e.deltaY) * 0.15 + setScale(prev => Math.min(Math.max(prev + delta, 0.1), 10)) + }, []) + + // 双击重置 + const handleDoubleClick = useCallback(() => { + handleReset() + }, [handleReset]) + + // 快捷键支持 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') window.electronAPI.window.close() + if (e.key === '=' || e.key === '+') handleZoomIn() + if (e.key === '-') handleZoomOut() + if (e.key === 'r' || e.key === 'R') handleRotate() + if (e.key === '0') handleReset() + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleReset]) + + if (!imagePath) { + return ( +
+ 无效的图片路径 +
+ ) + } + + const displayScale = initialScale * scale + + return ( +
+
+
+
+ + {Math.round(displayScale * 100)}% + +
+ + +
+
+ +
+ Preview +
+
+ ) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e00eeeb..3211b84 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -11,6 +11,7 @@ export interface ElectronAPI { setTitleBarOverlay: (options: { symbolColor: string }) => void openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise + openImageViewerWindow: (imagePath: string) => Promise openChatHistoryWindow: (sessionId: string, messageId: number) => Promise } config: {