给密语的图片查看器搬过来了

This commit is contained in:
xuncha
2026-02-02 18:20:26 +08:00
committed by xuncha
parent 5934fc33ce
commit 7b832ac2ef
7 changed files with 339 additions and 13 deletions

View File

@@ -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)}
/>
</div>
{showImagePreview && (
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
)}
</>
)}
</div>

View File

@@ -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);
}

162
src/pages/ImageWindow.tsx Normal file
View File

@@ -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<HTMLDivElement>(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<HTMLImageElement>) => {
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 (
<div className="image-window-empty">
<span></span>
</div>
)
}
const displayScale = initialScale * scale
return (
<div className="image-window-container">
<div className="title-bar">
<div className="window-drag-area"></div>
<div className="title-bar-controls">
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
<div className="divider"></div>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
</div>
</div>
<div
className="image-viewport"
ref={viewportRef}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
>
<img
src={imagePath}
alt="Preview"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
}}
onLoad={handleImageLoad}
draggable={false}
/>
</div>
</div>
)
}