cherry-studio/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx
one 3f97aef93f
fix: show x-scrollbar in codeblock if unwrapped, simplify style definitions (#6266)
* fix: show x-scrollbar in codeblock if unwrapped, simplify style definitions

* chore: clean up useless code
2025-05-21 23:39:23 +08:00

351 lines
11 KiB
TypeScript

import { download } from '@renderer/utils/download'
import { FileImage, ZoomIn, ZoomOut } from 'lucide-react'
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
import { TOOL_SPECS } from './constants'
import { useCodeToolbar } from './context'
// 预编译正则表达式用于查询位置
const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
/**
* 使用图像处理工具的自定义Hook
* 提供图像缩放、复制和下载功能
*/
export const usePreviewToolHandlers = (
containerRef: RefObject<HTMLDivElement | null>,
options: {
prefix: string
imgSelector: string
enableWheelZoom?: boolean
customDownloader?: (format: 'svg' | 'png') => void
}
) => {
const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态
const [renderTrigger, setRenderTrigger] = useState(0) // 仅用于触发组件重渲染的状态
const { imgSelector, prefix, customDownloader, enableWheelZoom } = options
const { t } = useTranslation()
// 创建选择器函数
const getImgElement = useCallback(() => {
if (!containerRef.current) return null
return containerRef.current.querySelector(imgSelector) as SVGElement | null
}, [containerRef, imgSelector])
// 查询当前位置
const getCurrentPosition = useCallback(() => {
const imgElement = getImgElement()
if (!imgElement) return { x: transformRef.current.x, y: transformRef.current.y }
const transform = imgElement.style.transform
if (!transform || transform === 'none') return { x: transformRef.current.x, y: transformRef.current.y }
const match = transform.match(TRANSFORM_REGEX)
if (match && match.length >= 3) {
return {
x: parseFloat(match[1]),
y: parseFloat(match[2])
}
}
return { x: transformRef.current.x, y: transformRef.current.y }
}, [getImgElement])
// 平移缩放变换
const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => {
if (!element) return
element.style.transformOrigin = 'top left'
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
}, [])
// 拖拽平移支持
useEffect(() => {
const container = containerRef.current
if (!container) return
let isDragging = false
const startPos = { x: 0, y: 0 }
const startOffset = { x: 0, y: 0 }
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return // 只响应左键
// 更新当前实际位置
const position = getCurrentPosition()
transformRef.current.x = position.x
transformRef.current.y = position.y
isDragging = true
startPos.x = e.clientX
startPos.y = e.clientY
startOffset.x = position.x
startOffset.y = position.y
container.style.cursor = 'grabbing'
e.preventDefault()
}
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - startPos.x
const dy = e.clientY - startPos.y
const newX = startOffset.x + dx
const newY = startOffset.y + dy
const imgElement = getImgElement()
applyTransform(imgElement, newX, newY, transformRef.current.scale)
e.preventDefault()
}
const stopDrag = () => {
if (!isDragging) return
// 更新位置但不立即触发状态变更
const position = getCurrentPosition()
transformRef.current.x = position.x
transformRef.current.y = position.y
// 只触发一次渲染以保持组件状态同步
setRenderTrigger((prev) => prev + 1)
isDragging = false
container.style.cursor = 'default'
}
// 绑定到document以确保拖拽可以在鼠标离开容器后继续
container.addEventListener('mousedown', onMouseDown)
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', stopDrag)
return () => {
container.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', stopDrag)
}
}, [containerRef, getCurrentPosition, getImgElement, applyTransform])
// 缩放处理函数
const handleZoom = useCallback(
(delta: number) => {
const newScale = Math.max(0.1, Math.min(3, transformRef.current.scale + delta))
transformRef.current.scale = newScale
const imgElement = getImgElement()
applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale)
// 触发重渲染以保持组件状态同步
setRenderTrigger((prev) => prev + 1)
},
[getImgElement, applyTransform]
)
// 滚轮缩放支持
useEffect(() => {
if (!enableWheelZoom || !containerRef.current) return
const container = containerRef.current
const handleWheel = (e: WheelEvent) => {
if ((e.ctrlKey || e.metaKey) && e.target) {
// 确认事件发生在容器内部
if (container.contains(e.target as Node)) {
const delta = e.deltaY < 0 ? 0.1 : -0.1
handleZoom(delta)
}
}
}
container.addEventListener('wheel', handleWheel, { passive: true })
return () => container.removeEventListener('wheel', handleWheel)
}, [containerRef, handleZoom, enableWheelZoom])
// 复制图像处理函数
const handleCopyImage = useCallback(async () => {
try {
const imgElement = getImgElement()
if (!imgElement) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(imgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = async () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
window.message.success(t('message.copy.success'))
}
}
img.src = svgBase64
} catch (error) {
console.error('Copy failed:', error)
window.message.error(t('message.copy.failed'))
}
}, [getImgElement, t])
// 下载处理函数
const handleDownload = useCallback(
(format: 'svg' | 'png') => {
// 如果有自定义下载器,使用自定义实现
if (customDownloader) {
customDownloader(format)
return
}
try {
const imgElement = getImgElement()
if (!imgElement) return
const timestamp = Date.now()
if (format === 'svg') {
const svgData = new XMLSerializer().serializeToString(imgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
download(url, `${prefix}-${timestamp}.svg`)
URL.revokeObjectURL(url)
} else if (format === 'png') {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(imgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
}
canvas.toBlob((blob) => {
if (blob) {
const pngUrl = URL.createObjectURL(blob)
download(pngUrl, `${prefix}-${timestamp}.png`)
URL.revokeObjectURL(pngUrl)
}
}, 'image/png')
}
img.src = svgBase64
}
} catch (error) {
console.error('Download failed:', error)
}
},
[getImgElement, prefix, customDownloader]
)
return {
scale: transformRef.current.scale,
handleZoom,
handleCopyImage,
handleDownload,
renderTrigger // 导出渲染触发器,万一要用
}
}
export interface PreviewToolsOptions {
handleZoom?: (delta: number) => void
handleCopyImage?: () => Promise<void>
handleDownload?: (format: 'svg' | 'png') => void
}
/**
* 提供预览组件通用工具栏功能的自定义Hook
*/
export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar()
useEffect(() => {
// 根据提供的功能有选择性地注册工具
if (handleZoom) {
// 放大工具
registerTool({
...TOOL_SPECS['zoom-in'],
icon: <ZoomIn className="icon" />,
tooltip: t('code_block.preview.zoom_in'),
onClick: () => handleZoom(0.1)
})
// 缩小工具
registerTool({
...TOOL_SPECS['zoom-out'],
icon: <ZoomOut className="icon" />,
tooltip: t('code_block.preview.zoom_out'),
onClick: () => handleZoom(-0.1)
})
}
if (handleCopyImage) {
// 复制图片工具
registerTool({
...TOOL_SPECS['copy-image'],
icon: <FileImage className="icon" />,
tooltip: t('code_block.preview.copy.image'),
onClick: handleCopyImage
})
}
if (handleDownload) {
// 下载 SVG 工具
registerTool({
...TOOL_SPECS['download-svg'],
icon: <DownloadSvgIcon />,
tooltip: t('code_block.download.svg'),
onClick: () => handleDownload('svg')
})
// 下载 PNG 工具
registerTool({
...TOOL_SPECS['download-png'],
icon: <DownloadPngIcon />,
tooltip: t('code_block.download.png'),
onClick: () => handleDownload('png')
})
}
// 清理函数
return () => {
if (handleZoom) {
removeTool(TOOL_SPECS['zoom-in'].id)
removeTool(TOOL_SPECS['zoom-out'].id)
}
if (handleCopyImage) {
removeTool(TOOL_SPECS['copy-image'].id)
}
if (handleDownload) {
removeTool(TOOL_SPECS['download-svg'].id)
removeTool(TOOL_SPECS['download-png'].id)
}
}
}, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t])
}