mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 04:31:27 +08:00
* fix: show x-scrollbar in codeblock if unwrapped, simplify style definitions * chore: clean up useless code
351 lines
11 KiB
TypeScript
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])
|
|
}
|