├── src
├── app
│ ├── favicon.ico
│ ├── layout.tsx
│ ├── globals.css
│ └── page.tsx
├── types.d.ts
└── components
│ ├── SampleSVGs.tsx
│ └── SVGViewer.tsx
├── public
├── vercel.svg
├── checkerboard.svg
├── window.svg
├── file.svg
├── samples
│ ├── logo.svg
│ └── chart.svg
├── globe.svg
└── next.svg
├── postcss.config.mjs
├── next.config.js
├── README.md
├── tailwind.config.ts
├── .gitignore
├── eslint.config.mjs
├── tsconfig.json
├── package.json
└── yarn.lock
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/liujuntao123/new-svg-viewer/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | experimental: {
6 | fontLoaders: [
7 | { loader: '@next/font/google', options: { subsets: ['latin'] } },
8 | ],
9 | },
10 | };
11 |
12 | module.exports = nextConfig;
--------------------------------------------------------------------------------
/public/checkerboard.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # New SVG Viewer
2 | 一个界面清爽,功能齐全的 SVG 预览网站
3 |
4 | 网站地址:https://new-svg-viewer.19921014.xyz/
5 |
6 | ## 特性
7 |
8 | - 一键粘贴SVG代码或上传SVG文件
9 | - SVG实时预览
10 | - 一键将SVG导出为PNG、JPEG或SVG,复制到剪贴板
11 | - SVG代码的语法高亮
12 | - 界面干净,纯免费,无广告
13 |
14 | 
15 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import "./globals.css";
3 |
4 | export const metadata: Metadata = {
5 | title: "SVG 预览",
6 | description: "SVG 预览",
7 | };
8 |
9 | export default function RootLayout({
10 | children,
11 | }: Readonly<{
12 | children: React.ReactNode;
13 | }>) {
14 | return (
15 |
16 |
17 | {children}
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | background: "var(--background)",
13 | foreground: "var(--foreground)",
14 | },
15 | },
16 | },
17 | plugins: [],
18 | } satisfies Config;
19 |
--------------------------------------------------------------------------------
/public/samples/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | {
15 | rules: {
16 | "no-console": "off",
17 | "no-undef": "off",
18 | "no-unused-vars": "off",
19 | "@typescript-eslint/no-explicit-any": "off",
20 | "@typescript-eslint/no-unused-vars": "off",
21 | },
22 | },
23 | ];
24 |
25 | export default eslintConfig;
26 |
--------------------------------------------------------------------------------
/src/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react';
2 | declare module 'react-dom';
3 | declare module 'file-saver';
4 | declare module 'html-to-image';
5 | declare module 'react-syntax-highlighter';
6 | declare module 'react-syntax-highlighter/dist/cjs/styles/hljs';
7 | declare module 'react-toastify';
8 | declare module 'next/dynamic';
9 |
10 | interface Window {
11 | FileReader: new () => FileReader;
12 | File: any;
13 | Blob: any;
14 | URL: {
15 | createObjectURL(blob: Blob): string;
16 | revokeObjectURL(url: string): void;
17 | };
18 | }
19 |
20 | interface FileReader {
21 | readAsText(file: Blob): void;
22 | onload: ((this: FileReader, ev: ProgressEvent) => any) | null;
23 | result: string | ArrayBuffer | null;
24 | }
25 |
26 | interface Navigator {
27 | clipboard: {
28 | writeText(text: string): Promise;
29 | };
30 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "noImplicitAny": false,
13 | "checkJs": false,
14 | "noEmit": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "bundler",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve",
21 | "incremental": true,
22 | "plugins": [
23 | {
24 | "name": "next"
25 | }
26 | ],
27 | "paths": {
28 | "@/*": [
29 | "./src/*"
30 | ]
31 | },
32 | "forceConsistentCasingInFileNames": true
33 | },
34 | "include": [
35 | "next-env.d.ts",
36 | "**/*.ts",
37 | "**/*.tsx",
38 | ".next/types/**/*.ts"
39 | ],
40 | "exclude": [
41 | "node_modules"
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --background: #ffffff;
7 | --foreground: #171717;
8 | }
9 |
10 | @media (prefers-color-scheme: dark) {
11 | :root {
12 | --background: #0a0a0a;
13 | --foreground: #ededed;
14 | }
15 | }
16 |
17 | body {
18 | color: var(--foreground);
19 | background: var(--background);
20 | font-family: Arial, Helvetica, sans-serif;
21 | }
22 |
23 | * {
24 | margin: 0;
25 | padding: 0;
26 | box-sizing: border-box;
27 | }
28 |
29 | h1, h2, h3, h4, h5, h6 {
30 | font-weight: normal;
31 | margin: 0; /* Reset margin for headings */
32 | }
33 |
34 | ul, ol {
35 | list-style: none;
36 | margin: 0; /* Reset margin for lists */
37 | padding: 0; /* Reset padding for lists */
38 | }
39 |
40 | a {
41 | text-decoration: none;
42 | color: inherit;
43 | margin: 0; /* Reset margin for links */
44 | }
45 |
46 | img {
47 | max-width: 100%;
48 | height: auto;
49 | display: block; /* Prevents bottom space in images */
50 | margin: 0; /* Reset margin for images */
51 | }
52 |
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "new-svg-viewer",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@codemirror/lang-xml": "^6.1.0",
13 | "@codemirror/state": "^6.5.2",
14 | "@codemirror/theme-one-dark": "^6.1.2",
15 | "@codemirror/view": "^6.36.3",
16 | "codemirror": "^6.0.1",
17 | "file-saver": "^2.0.5",
18 | "html-to-image": "^1.11.13",
19 | "lucide-react": "^0.476.0",
20 | "next": "15.1.7",
21 | "react": "^19.0.0",
22 | "react-codemirror2": "^8.0.1",
23 | "react-dom": "^19.0.0",
24 | "react-icons": "^5.5.0",
25 | "react-syntax-highlighter": "^15.6.1",
26 | "react-toastify": "^11.0.5"
27 | },
28 | "devDependencies": {
29 | "@eslint/eslintrc": "^3",
30 | "@types/node": "22.13.5",
31 | "@types/react": "19.0.10",
32 | "@types/react-dom": "^19",
33 | "eslint": "^9",
34 | "eslint-config-next": "15.1.7",
35 | "postcss": "^8",
36 | "tailwindcss": "^3.4.1",
37 | "typescript": "5.7.3"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/SampleSVGs.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | interface SampleSVGsProps {
4 | onSelectSample: (svgCode: string) => void;
5 | }
6 |
7 | const samples = [
8 | {
9 | name: 'Logo',
10 | path: '/samples/logo.svg'
11 | },
12 | {
13 | name: 'Chart',
14 | path: '/samples/chart.svg'
15 | }
16 | ];
17 |
18 | const SampleSVGs: React.FC = ({ onSelectSample }) => {
19 | const [loading, setLoading] = useState({});
20 |
21 | const loadSampleSVG = async (path: string) => {
22 | setLoading((prev: Record) => ({ ...prev, [path]: true }));
23 |
24 | try {
25 | const response = await fetch(path);
26 | const svgCode = await response.text();
27 | onSelectSample(svgCode);
28 | } catch (error) {
29 | console.error('Failed to load sample SVG:', error);
30 | } finally {
31 | setLoading((prev: Record) => ({ ...prev, [path]: false }));
32 | }
33 | };
34 |
35 | return (
36 |
37 |
示例:
38 |
39 | {samples.map((sample) => (
40 |
48 | ))}
49 |
50 |
51 | );
52 | };
53 |
54 | export default SampleSVGs;
--------------------------------------------------------------------------------
/public/samples/chart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ToastContainer } from 'react-toastify';
4 | import 'react-toastify/dist/ReactToastify.css';
5 | import dynamic from 'next/dynamic';
6 |
7 | // Use dynamic import to avoid SSR issues with browser-specific APIs
8 | const SVGViewer = dynamic(() => import('../components/SVGViewer'), { ssr: false });
9 |
10 | export default function Home() {
11 | return (
12 |
13 |
18 |
19 |
20 |
21 |
22 |
26 | SVG 预览
27 |
28 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/SVGViewer.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from 'react';
2 | import { toPng, toJpeg, toSvg } from 'html-to-image';
3 | import { saveAs } from 'file-saver';
4 | import { toast } from 'react-toastify';
5 | import { Copy, Download, Image, Upload, ZoomIn, ZoomOut } from 'lucide-react';
6 | import SampleSVGs from './SampleSVGs';
7 | import { EditorState } from '@codemirror/state';
8 | import { EditorView } from '@codemirror/view';
9 | import { xml } from '@codemirror/lang-xml';
10 | import { oneDark } from '@codemirror/theme-one-dark';
11 | import { syntaxHighlighting ,defaultHighlightStyle} from '@codemirror/language';
12 |
13 |
14 | interface SVGViewerProps {
15 | initialSvg?: string;
16 | }
17 |
18 | // 自定义高亮配置(可选)
19 | const svgHighlight = syntaxHighlighting(defaultHighlightStyle, { fallback: true });
20 |
21 | // CodeMirror 扩展
22 | const extensions = [
23 | xml(),
24 | oneDark,
25 | EditorView.lineWrapping,
26 | svgHighlight,
27 | EditorView.theme({
28 | "&": {
29 | height: "100%", // 设置编辑器的高度为100%
30 | }
31 | })
32 | ];
33 | /* eslint-disable */
34 | const SVGViewer: React.FC = ({ initialSvg = '' }) => {
35 | const [svgCode, setSvgCode] = useState(initialSvg);
36 | const [fileName, setFileName] = useState('svg-preview');
37 | const [scale, setScale] = useState(1);
38 | const [position, setPosition] = useState({ x: 0, y: 0 });
39 | const [isDragging, setIsDragging] = useState(false);
40 | const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
41 | const previewRef = useRef(null);
42 | const fileInputRef = useRef(null);
43 | const wheelTimeoutRef = useRef(null);
44 | const editorRef = useRef(null);
45 | const viewRef = useRef(null);
46 | const [previewSvgCode, setPreviewSvgCode] = useState('');
47 |
48 | // 确保 SVG 包含宽度和高度属性
49 | const ensureSvgDimensions = (svgCode: string): string => {
50 | if (!svgCode || !isValidSvg(svgCode)) return svgCode;
51 |
52 | const parser = new DOMParser();
53 | const doc = parser.parseFromString(svgCode, 'image/svg+xml');
54 | const svgElement = doc.querySelector('svg');
55 |
56 | if (!svgElement) return svgCode;
57 |
58 | // 如果没有 width 或 height 属性,并且有 viewBox
59 | if ((!svgElement.hasAttribute('width') || !svgElement.hasAttribute('height')) && svgElement.hasAttribute('viewBox')) {
60 | const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number);
61 | if (viewBox && viewBox.length === 4) {
62 | const [, , width, height] = viewBox;
63 | if (!svgElement.hasAttribute('width')) {
64 | svgElement.setAttribute('width', width.toString());
65 | }
66 | if (!svgElement.hasAttribute('height')) {
67 | svgElement.setAttribute('height', height.toString());
68 | }
69 | }
70 | }
71 |
72 | // 如果仍然没有宽度和高度,设置默认值
73 | if (!svgElement.hasAttribute('width')) {
74 | svgElement.setAttribute('width', '300');
75 | }
76 | if (!svgElement.hasAttribute('height')) {
77 | svgElement.setAttribute('height', '300');
78 | }
79 |
80 | return new XMLSerializer().serializeToString(doc);
81 | };
82 |
83 | const handleSvgChange = (e: React.ChangeEvent) => {
84 | setSvgCode(e.target.value);
85 | };
86 |
87 | const handleFileNameChange = (e: React.ChangeEvent) => {
88 | setFileName(e.target.value);
89 | };
90 |
91 | const handleFileUpload = (e: React.ChangeEvent) => {
92 | const file = e.target.files?.[0];
93 | if (!file) return;
94 |
95 | // Update filename without extension
96 | const nameWithoutExt = file.name.replace(/\.[^/.]+$/, "");
97 | setFileName(nameWithoutExt);
98 |
99 | const reader = new FileReader();
100 | reader.onload = (event) => {
101 | const content = event.target?.result as string;
102 | setSvgCode(content);
103 | };
104 | reader.readAsText(file);
105 | };
106 |
107 | const triggerFileInput = () => {
108 | fileInputRef.current?.click();
109 | };
110 |
111 | const exportAsImage = async (format: 'png' | 'jpeg' | 'svg') => {
112 | if (!previewRef.current) return;
113 |
114 | try {
115 | let dataUrl: string;
116 |
117 | switch (format) {
118 | case 'png':
119 | dataUrl = await toPng(previewRef.current, { backgroundColor: 'white' });
120 | saveAs(dataUrl, `${fileName}.png`);
121 | break;
122 | case 'jpeg':
123 | dataUrl = await toJpeg(previewRef.current, { backgroundColor: 'white' });
124 | saveAs(dataUrl, `${fileName}.jpg`);
125 | break;
126 | case 'svg':
127 | dataUrl = await toSvg(previewRef.current, { backgroundColor: 'white' });
128 | saveAs(dataUrl, `${fileName}.svg`);
129 | break;
130 | }
131 |
132 | toast.success(`导出为 ${format.toUpperCase()}`);
133 | } catch (error) {
134 | console.error('Export failed:', error);
135 | toast.error('Export failed. Please try again.');
136 | }
137 | };
138 |
139 | const downloadSvgCode = () => {
140 | if (!svgCode.trim()) {
141 | toast.error('没有 SVG 代码可下载');
142 | return;
143 | }
144 |
145 | const blob = new Blob([svgCode], { type: 'text/plain;charset=utf-8' });
146 | saveAs(blob, `${fileName}.svg`);
147 | toast.success('SVG 代码已下载');
148 | };
149 |
150 | const copySvgCode = async () => {
151 | if (!svgCode.trim()) {
152 | toast.error('没有 SVG 代码可复制');
153 | return;
154 | }
155 |
156 | try {
157 | await navigator.clipboard.writeText(svgCode);
158 | toast.success('SVG 代码已复制到剪贴板');
159 | } catch (error) {
160 | console.error('Copy failed:', error);
161 | toast.error('复制到剪贴板失败');
162 | }
163 | };
164 |
165 | const handleScaleChange = (e: React.ChangeEvent) => {
166 | setScale(parseFloat(e.target.value));
167 | };
168 |
169 | const zoomIn = () => {
170 | setScale(prev => Math.min(prev + 0.1, 3));
171 | };
172 |
173 | const zoomOut = () => {
174 | setScale(prev => Math.max(prev - 0.1, 0.1));
175 | };
176 |
177 | // Reset position when scale changes
178 | const resetPosition = () => {
179 | setPosition({ x: 0, y: 0 });
180 | };
181 |
182 | // Detect if SVG is valid
183 | const isValidSvg = (code: string): boolean => {
184 | return /