Skip to content

Commit 3635a87

Browse files
committed
feat: SvgPreview source component
1 parent 5ec4faa commit 3635a87

2 files changed

Lines changed: 326 additions & 367 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import React, { useEffect, useRef, useState } from 'react'
2+
import { Button, message } from 'antd'
3+
import { ZoomInOutlined, ZoomOutOutlined, SyncOutlined, DownloadOutlined } from '@ant-design/icons'
4+
5+
const defaultBg = '#1e1e1e'
6+
7+
function SvgPreview({
8+
svgSource = '',
9+
svgUrl = '',
10+
SvgComponent = null,
11+
downloadName = 'diagram.svg',
12+
background = defaultBg,
13+
}) {
14+
const containerRef = useRef(null)
15+
const innerRef = useRef(null)
16+
17+
const [content, setContent] = useState(svgSource || '')
18+
const [scale, setScale] = useState(0.6)
19+
const scaleRef = useRef(scale)
20+
const [position, setPosition] = useState({ x: 0, y: 0 })
21+
const positionRef = useRef(position)
22+
const [isDragging, setIsDragging] = useState(false)
23+
const isDraggingRef = useRef(isDragging)
24+
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
25+
const startPosRef = useRef(startPos)
26+
27+
useEffect(() => {
28+
scaleRef.current = scale
29+
}, [scale])
30+
useEffect(() => {
31+
positionRef.current = position
32+
}, [position])
33+
useEffect(() => {
34+
isDraggingRef.current = isDragging
35+
}, [isDragging])
36+
useEffect(() => {
37+
startPosRef.current = startPos
38+
}, [startPos])
39+
40+
// load remote svg if svgUrl provided
41+
useEffect(() => {
42+
let mounted = true
43+
// if a React SvgComponent is provided, prefer rendering it instead of loading string
44+
if (SvgComponent) {
45+
if (mounted) setContent('')
46+
return () => {
47+
mounted = false
48+
}
49+
}
50+
51+
if (svgUrl) {
52+
fetch(svgUrl)
53+
.then((r) => r.text())
54+
.then((t) => {
55+
if (mounted) setContent(t)
56+
})
57+
.catch((e) => console.log('error', e.message))
58+
} else {
59+
setContent(svgSource)
60+
}
61+
return () => (mounted = false)
62+
}, [svgUrl, svgSource])
63+
64+
// center on mount / when content changes
65+
useEffect(() => {
66+
const el = containerRef.current
67+
const inner = innerRef.current
68+
if (!el || !inner) return
69+
const svg = inner.querySelector('svg')
70+
if (!svg) return
71+
// apply rendering hints
72+
try {
73+
svg.setAttribute('shape-rendering', 'geometricPrecision')
74+
svg.setAttribute('text-rendering', 'geometricPrecision')
75+
svg.style.imageRendering = 'optimizeQuality'
76+
} catch (e) {
77+
console.log('error', e.message)
78+
}
79+
80+
const cRect = el.getBoundingClientRect()
81+
const sRect = svg.getBoundingClientRect()
82+
const cx = (cRect.width - sRect.width) / 2
83+
const cy = (cRect.height - sRect.height) / 2
84+
positionRef.current = { x: cx, y: cy }
85+
setPosition({ x: cx, y: cy })
86+
positionRef.current.__scale = scaleRef.current
87+
}, [content, SvgComponent])
88+
89+
// native handlers for drag/touch/wheel
90+
useEffect(() => {
91+
const el = containerRef.current
92+
if (!el) return
93+
94+
const onMouseDown = (e) => {
95+
setIsDragging(true)
96+
isDraggingRef.current = true
97+
const x = e.clientX - positionRef.current.x
98+
const y = e.clientY - positionRef.current.y
99+
setStartPos({ x, y })
100+
startPosRef.current = { x, y }
101+
}
102+
103+
const onTouchStart = (e) => {
104+
if (!e.touches || e.touches.length === 0) return
105+
const t = e.touches[0]
106+
setIsDragging(true)
107+
isDraggingRef.current = true
108+
const x = t.clientX - positionRef.current.x
109+
const y = t.clientY - positionRef.current.y
110+
setStartPos({ x, y })
111+
startPosRef.current = { x, y }
112+
}
113+
114+
const onTouchMove = (e) => {
115+
if (!isDraggingRef.current || !e.touches || e.touches.length === 0) return
116+
e.preventDefault()
117+
const t = e.touches[0]
118+
setPosition({ x: t.clientX - startPosRef.current.x, y: t.clientY - startPosRef.current.y })
119+
}
120+
121+
const onTouchEnd = () => {
122+
setIsDragging(false)
123+
isDraggingRef.current = false
124+
}
125+
126+
const onWheel = (e) => {
127+
e.preventDefault()
128+
const rect = el.getBoundingClientRect()
129+
const mx = e.clientX - rect.left
130+
const my = e.clientY - rect.top
131+
const curScale = positionRef.current?.__scale || scaleRef.current || scale
132+
const factor = e.deltaY < 0 ? 1.12 : 0.88
133+
const ns = Math.min(5, Math.max(0.1, curScale * factor))
134+
const ratio = ns / curScale
135+
const curPos = positionRef.current || { x: 0, y: 0 }
136+
const newX = mx - (mx - curPos.x) * ratio
137+
const newY = my - (my - curPos.y) * ratio
138+
positionRef.current = { x: newX, y: newY }
139+
setPosition({ x: newX, y: newY })
140+
positionRef.current.__scale = ns
141+
scaleRef.current = ns
142+
setScale(ns)
143+
}
144+
145+
el.addEventListener('mousedown', onMouseDown)
146+
el.addEventListener('touchstart', onTouchStart, { passive: true })
147+
el.addEventListener('touchmove', onTouchMove, { passive: false })
148+
el.addEventListener('touchend', onTouchEnd)
149+
el.addEventListener('wheel', onWheel, { passive: false })
150+
151+
// mousemove / up on window to track dragging
152+
const onMouseMove = (e) => {
153+
if (!isDraggingRef.current) return
154+
setPosition({ x: e.clientX - startPosRef.current.x, y: e.clientY - startPosRef.current.y })
155+
}
156+
const onMouseUp = () => setIsDragging(false)
157+
window.addEventListener('mousemove', onMouseMove)
158+
window.addEventListener('mouseup', onMouseUp)
159+
160+
return () => {
161+
el.removeEventListener('mousedown', onMouseDown)
162+
el.removeEventListener('touchstart', onTouchStart)
163+
el.removeEventListener('touchmove', onTouchMove)
164+
el.removeEventListener('touchend', onTouchEnd)
165+
el.removeEventListener('wheel', onWheel)
166+
window.removeEventListener('mousemove', onMouseMove)
167+
window.removeEventListener('mouseup', onMouseUp)
168+
}
169+
}, [])
170+
171+
const handleZoomIn = () => {
172+
setScale((prev) => {
173+
const ns = Math.min(prev + 0.2, 5)
174+
scaleRef.current = ns
175+
return ns
176+
})
177+
}
178+
const handleZoomOut = () => {
179+
setScale((prev) => {
180+
const ns = Math.max(prev - 0.2, 0.1)
181+
scaleRef.current = ns
182+
return ns
183+
})
184+
}
185+
const handleReset = () => {
186+
setScale(0.6)
187+
scaleRef.current = 0.6
188+
setPosition({ x: 0, y: 0 })
189+
message.info('视图已重置')
190+
}
191+
192+
const prepareSvgForDownload = (raw) => {
193+
try {
194+
let svg = raw.replace(/<br>/g, '<br/>')
195+
if (!/xmlns="http:\/\/www.w3.org\/2000\/svg"/.test(svg)) {
196+
svg = svg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"')
197+
}
198+
if (!/id="__download_bg__"/.test(svg)) {
199+
svg = svg.replace(
200+
/(<svg[^>]*>)/i,
201+
`$1<rect id="__download_bg__" width="100%" height="100%" fill="${background}"/>`
202+
)
203+
}
204+
return svg
205+
} catch (e) {
206+
console.log('error', e.message)
207+
return raw
208+
}
209+
}
210+
211+
const handleDownload = async () => {
212+
try {
213+
let raw = content
214+
if (!raw && svgUrl) {
215+
const r = await fetch(svgUrl)
216+
raw = await r.text()
217+
}
218+
// if no raw string but a SvgComponent is rendered, serialize the rendered SVG DOM
219+
if (!raw && SvgComponent && innerRef.current) {
220+
try {
221+
const svgNode = innerRef?.current?.querySelector('svg')
222+
if (svgNode) {
223+
const cloned = svgNode.cloneNode(true)
224+
if (!cloned.getAttribute('xmlns')) {
225+
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
226+
}
227+
const ser = new XMLSerializer().serializeToString(cloned)
228+
raw = ser
229+
}
230+
} catch (e) {
231+
console.log('error', e.message)
232+
}
233+
}
234+
if (!raw) throw new Error('no svg content')
235+
const svg = prepareSvgForDownload(raw)
236+
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })
237+
const url = URL.createObjectURL(blob)
238+
const a = document.createElement('a')
239+
a.href = url
240+
a.download = downloadName
241+
document.body.appendChild(a)
242+
a.click()
243+
a.remove()
244+
URL.revokeObjectURL(url)
245+
message.success('SVG 下载已开始')
246+
} catch (e) {
247+
console.log('error', e.message)
248+
message.error('下载失败')
249+
}
250+
}
251+
252+
return (
253+
<div style={{ height: '100%', position: 'relative' }}>
254+
<div
255+
ref={containerRef}
256+
role="application"
257+
aria-label="SVG viewer"
258+
style={{
259+
width: '100%',
260+
height: '100%',
261+
backgroundColor: background,
262+
overflow: 'hidden',
263+
cursor: isDragging ? 'grabbing' : 'grab',
264+
position: 'relative',
265+
}}
266+
>
267+
<div
268+
ref={innerRef}
269+
style={{
270+
transformOrigin: '0 0',
271+
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
272+
transition: isDragging ? 'none' : 'transform 0.1s ease-out',
273+
willChange: 'transform',
274+
}}
275+
>
276+
{SvgComponent ? (
277+
<SvgComponent style={{ width: '100%', height: '100%' }} />
278+
) : (
279+
<div dangerouslySetInnerHTML={{ __html: content ? content.replace(/<br>/g, '<br/>') : '' }} />
280+
)}
281+
</div>
282+
</div>
283+
284+
<div
285+
aria-hidden
286+
style={{
287+
position: 'absolute',
288+
bottom: 12,
289+
right: 12,
290+
zIndex: 1200,
291+
background: 'rgba(0,0,0,0.45)',
292+
padding: '6px 10px',
293+
borderRadius: 10,
294+
backdropFilter: 'blur(6px)',
295+
display: 'flex',
296+
alignItems: 'center',
297+
gap: 8,
298+
boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
299+
}}
300+
>
301+
<Button icon={<ZoomOutOutlined />} onClick={handleZoomOut} type="text" style={{ color: '#fff' }} />
302+
<div style={{ color: '#fff', minWidth: 56, textAlign: 'center', fontWeight: 500 }}>
303+
{Math.round(scale * 100)}%
304+
</div>
305+
<Button icon={<ZoomInOutlined />} onClick={handleZoomIn} type="text" style={{ color: '#fff' }} />
306+
<Button icon={<SyncOutlined />} onClick={handleReset} type="text" style={{ color: '#fff' }} />
307+
<Button icon={<DownloadOutlined />} onClick={handleDownload} type="primary" size="small">
308+
下载
309+
</Button>
310+
</div>
311+
</div>
312+
)
313+
}
314+
315+
export default SvgPreview

src/pages/svgViewer/index.jsx

Lines changed: 11 additions & 367 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)