From aa44f6c105cb0a63d364bd854714c43f9fb05cb3 Mon Sep 17 00:00:00 2001 From: achraf Date: Sun, 7 Jun 2026 01:08:05 +0200 Subject: [PATCH] Add SVG viewer and Umami analytics --- index.html | 1 + src/core/plugins/plugin-registry.ts | 2 + src/tools/svg-viewer/SvgViewerUi.tsx | 323 +++++++++++++++++++++++++++ src/tools/svg-viewer/index.ts | 38 ++++ 4 files changed, 364 insertions(+) create mode 100644 src/tools/svg-viewer/SvgViewerUi.tsx create mode 100644 src/tools/svg-viewer/index.ts diff --git a/index.html b/index.html index 075df22..9492f06 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + Plimi diff --git a/src/core/plugins/plugin-registry.ts b/src/core/plugins/plugin-registry.ts index cc00dba..90a1b00 100644 --- a/src/core/plugins/plugin-registry.ts +++ b/src/core/plugins/plugin-registry.ts @@ -25,6 +25,7 @@ import { csvToolsPlugin } from "../../tools/csv-tools"; import { qrCodeGeneratorPlugin } from "../../tools/qr-code-generator"; import { passwordGeneratorPlugin } from "../../tools/password-generator"; import { fileChecksumVerifierPlugin } from "../../tools/file-checksum-verifier"; +import { svgViewerPlugin } from "../../tools/svg-viewer"; function erasePlugin(plugin: PlimiPlugin): UnknownPlimiPlugin { return plugin as unknown as UnknownPlimiPlugin; @@ -57,6 +58,7 @@ export const pluginRegistry: UnknownPlimiPlugin[] = [ erasePlugin(qrCodeGeneratorPlugin), erasePlugin(passwordGeneratorPlugin), erasePlugin(fileChecksumVerifierPlugin), + erasePlugin(svgViewerPlugin), ]; export function getAllPlugins(): UnknownPlimiPlugin[] { diff --git a/src/tools/svg-viewer/SvgViewerUi.tsx b/src/tools/svg-viewer/SvgViewerUi.tsx new file mode 100644 index 0000000..5a91c69 --- /dev/null +++ b/src/tools/svg-viewer/SvgViewerUi.tsx @@ -0,0 +1,323 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../../components/ui/Button"; +import { Dropzone } from "../../components/ui/Dropzone"; + +type PreviewBackground = "light" | "dark" | "grid"; + +interface SvgMetadata { + width?: string; + height?: string; + viewBox?: string; + title?: string; + elementCount: number; +} + +const SAMPLE_SVG = ` + + + + + + + + + +`; + +function parseSvg(source: string): { metadata?: SvgMetadata; error?: string } { + const parser = new DOMParser(); + const document = parser.parseFromString(source, "image/svg+xml"); + const parserError = document.querySelector("parsererror"); + if (parserError) { + return { error: "The SVG contains invalid XML." }; + } + + const root = document.documentElement; + if (root.localName.toLowerCase() !== "svg") { + return { error: "The document must have an root element." }; + } + + return { + metadata: { + width: root.getAttribute("width") ?? undefined, + height: root.getAttribute("height") ?? undefined, + viewBox: root.getAttribute("viewBox") ?? undefined, + title: root.querySelector("title")?.textContent?.trim() || undefined, + elementCount: root.querySelectorAll("*").length, + }, + }; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function downloadSvg(source: string, fileName: string) { + const url = URL.createObjectURL( + new Blob([source], { type: "image/svg+xml;charset=utf-8" }) + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} + +export default function SvgViewerUi() { + const previewRef = useRef(null); + const [draft, setDraft] = useState(SAMPLE_SVG); + const [source, setSource] = useState(SAMPLE_SVG); + const [fileName, setFileName] = useState("plimi-sample.svg"); + const [error, setError] = useState(); + const [zoom, setZoom] = useState(1); + const [fit, setFit] = useState(true); + const [background, setBackground] = useState("grid"); + const [showSource, setShowSource] = useState(false); + const [copied, setCopied] = useState(false); + const [naturalSize, setNaturalSize] = useState({ width: 320, height: 220 }); + const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 }); + + const parsed = useMemo(() => parseSvg(source), [source]); + const metadata = parsed.metadata; + const sourceSize = useMemo(() => new Blob([source]).size, [source]); + const previewUrl = useMemo( + () => + URL.createObjectURL( + new Blob([source], { type: "image/svg+xml;charset=utf-8" }) + ), + [source] + ); + + useEffect(() => () => URL.revokeObjectURL(previewUrl), [previewUrl]); + + useEffect(() => { + const element = previewRef.current; + if (!element) return; + const observer = new ResizeObserver(([entry]) => { + setPreviewSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const applySource = useCallback( + (nextSource = draft, nextName = fileName) => { + const result = parseSvg(nextSource); + if (result.error) { + setError(result.error); + return; + } + setError(undefined); + setDraft(nextSource); + setSource(nextSource); + setFileName(nextName.toLowerCase().endsWith(".svg") ? nextName : `${nextName}.svg`); + setFit(true); + setZoom(1); + }, + [draft, fileName] + ); + + const loadFile = useCallback( + async (file: File) => { + try { + const text = await file.text(); + applySource(text, file.name); + } catch { + setError(`Could not read ${file.name}.`); + } + }, + [applySource] + ); + + const fitScale = Math.min( + 1, + previewSize.width > 48 ? (previewSize.width - 48) / naturalSize.width : 1, + previewSize.height > 48 ? (previewSize.height - 48) / naturalSize.height : 1 + ); + const displayScale = fit ? fitScale : zoom; + + const backgroundStyle = + background === "grid" + ? { + backgroundColor: "#ffffff", + backgroundImage: + "linear-gradient(45deg, #e5e7eb 25%, transparent 25%), linear-gradient(-45deg, #e5e7eb 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e7eb 75%), linear-gradient(-45deg, transparent 75%, #e5e7eb 75%)", + backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0", + backgroundSize: "16px 16px", + } + : { backgroundColor: background === "dark" ? "#111318" : "#ffffff" }; + + return ( +
+
+ { + if (files[0]) void loadFile(files[0]); + }} + className="min-h-0 w-full p-3 md:w-[280px]" + /> + +
+ + + + + {(["grid", "light", "dark"] as PreviewBackground[]).map((value) => ( + + ))} +
+ {error && !showSource && ( +
+ {error} +
+ )} +
+ +
+
+
+ {metadata?.title { + const image = event.currentTarget; + setNaturalSize({ + width: image.naturalWidth || 300, + height: image.naturalHeight || 150, + }); + }} + onError={() => setError("The browser could not render this SVG.")} + style={{ + width: Math.max(1, naturalSize.width * displayScale), + height: Math.max(1, naturalSize.height * displayScale), + maxWidth: "none", + flex: "none", + }} + /> +
+
+ + +
+ + {showSource && ( +
+ +