Add SVG viewer and Umami analytics

This commit is contained in:
achraf
2026-06-07 01:08:05 +02:00
parent bd72476bd7
commit aa44f6c105
4 changed files with 364 additions and 0 deletions

View File

@@ -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<TOptions>(plugin: PlimiPlugin<TOptions>): 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[] {

View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="220" viewBox="0 0 320 220">
<defs>
<linearGradient id="paint" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#A8FF60"/>
<stop offset="1" stop-color="#448AFF"/>
</linearGradient>
</defs>
<rect width="320" height="220" rx="28" fill="#161922"/>
<path d="M70 154 118 66l43 69 30-46 59 65" fill="none" stroke="url(#paint)" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="231" cy="60" r="18" fill="#FFD740"/>
</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 <svg> 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<HTMLDivElement | null>(null);
const [draft, setDraft] = useState(SAMPLE_SVG);
const [source, setSource] = useState(SAMPLE_SVG);
const [fileName, setFileName] = useState("plimi-sample.svg");
const [error, setError] = useState<string>();
const [zoom, setZoom] = useState(1);
const [fit, setFit] = useState(true);
const [background, setBackground] = useState<PreviewBackground>("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 (
<div className="flex min-h-[540px] flex-col bg-[var(--p-surface)]">
<div className="flex flex-col gap-3 border-b border-[var(--p-border)] p-3 md:flex-row md:items-center md:p-4">
<Dropzone
accept="image/svg+xml,.svg"
multiple={false}
maxSizeMb={10}
onFilesDrop={(files) => {
if (files[0]) void loadFile(files[0]);
}}
className="min-h-0 w-full p-3 md:w-[280px]"
/>
<div className="flex flex-wrap items-center gap-2 md:ml-auto">
<Button
variant="secondary"
onClick={() => {
setFit(false);
setZoom((value) => Math.max(0.1, value - 0.25));
}}
>
-
</Button>
<button
className="min-w-[72px] rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
onClick={() => setFit(true)}
>
{fit ? "Fit" : `${Math.round(zoom * 100)}%`}
</button>
<Button
variant="secondary"
onClick={() => {
setFit(false);
setZoom((value) => Math.min(4, value + 0.25));
}}
>
+
</Button>
<Button
variant="secondary"
onClick={() => {
setFit(false);
setZoom(1);
}}
>
100%
</Button>
{(["grid", "light", "dark"] as PreviewBackground[]).map((value) => (
<button
key={value}
onClick={() => setBackground(value)}
className={`rounded-[10px] border px-3 py-2 font-sans text-[12px] capitalize ${
background === value
? "border-[var(--p-accent)] bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "border-[var(--p-border)] bg-[var(--p-chip)] text-[var(--p-text)]"
}`}
>
{value}
</button>
))}
</div>
{error && !showSource && (
<div className="w-full font-sans text-[12px] text-red-600 md:basis-full">
{error}
</div>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col md:grid md:grid-cols-[minmax(0,1fr)_260px]">
<div
ref={previewRef}
className="min-h-[320px] overflow-auto p-6"
style={backgroundStyle}
>
<div className="flex min-h-full min-w-full items-center justify-center">
<img
src={previewUrl}
alt={metadata?.title || `Preview of ${fileName}`}
draggable={false}
onLoad={(event) => {
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",
}}
/>
</div>
</div>
<aside className="flex flex-col gap-4 border-t border-[var(--p-border)] bg-[var(--p-surface-2)] p-4 md:border-l md:border-t-0">
<section>
<div className="mb-2 font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
document
</div>
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
<div className="truncate font-sans text-sm font-semibold text-[var(--p-text)]">
{fileName}
</div>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 font-mono text-[11px]">
<dt className="text-[var(--p-muted)]">Size</dt>
<dd className="m-0 text-right text-[var(--p-text)]">{formatBytes(sourceSize)}</dd>
<dt className="text-[var(--p-muted)]">Width</dt>
<dd className="m-0 text-right text-[var(--p-text)]">{metadata?.width || "auto"}</dd>
<dt className="text-[var(--p-muted)]">Height</dt>
<dd className="m-0 text-right text-[var(--p-text)]">{metadata?.height || "auto"}</dd>
<dt className="text-[var(--p-muted)]">ViewBox</dt>
<dd className="m-0 truncate text-right text-[var(--p-text)]" title={metadata?.viewBox}>
{metadata?.viewBox || "none"}
</dd>
<dt className="text-[var(--p-muted)]">Elements</dt>
<dd className="m-0 text-right text-[var(--p-text)]">{metadata?.elementCount ?? 0}</dd>
</dl>
</div>
</section>
<section className="flex flex-col gap-2">
<Button variant="secondary" onClick={() => setShowSource((value) => !value)}>
{showSource ? "Hide source" : "View source"}
</Button>
<Button
variant="secondary"
onClick={async () => {
await navigator.clipboard.writeText(source);
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
}}
>
{copied ? "Copied" : "Copy SVG"}
</Button>
<Button onClick={() => downloadSvg(source, fileName)}>
Download SVG
</Button>
</section>
<p className="mt-auto font-mono text-[10px] leading-relaxed text-[var(--p-muted)]">
Previewed locally as an image. SVG scripts are not executed in the page.
</p>
</aside>
</div>
{showSource && (
<div className="border-t border-[var(--p-border)] bg-[var(--p-surface)] p-4">
<label className="mb-2 block font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
SVG source
</label>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
spellCheck={false}
rows={10}
className="w-full resize-y rounded-[12px] border border-[var(--p-border)] bg-[var(--p-bg)] p-3 font-mono text-[12px] leading-relaxed text-[var(--p-text)] outline-none focus:border-[var(--p-accent)]"
/>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="font-sans text-[12px] text-red-600">{error}</span>
<Button onClick={() => applySource()}>Apply source</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { lazy } from "react";
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
export type SvgViewerOptions = Record<string, never>;
const SvgViewerUi = lazy(() => import("./SvgViewerUi"));
export const svgViewerPlugin: PlimiPlugin<SvgViewerOptions> = {
manifest: {
id: "svg-viewer",
name: "SVG Viewer",
description:
"Open, inspect, zoom, and download SVG files entirely in your browser.",
category: "image",
version: "1.0.0",
tags: ["svg", "vector", "viewer", "preview", "xml"],
input: {
type: "text-or-files",
accept: ["image/svg+xml", ".svg"],
maxSizeMb: 10,
},
output: { type: "files" },
offlineReady: true,
},
capabilities: {
customUi: true,
preview: true,
},
permissions: {
network: "none",
clipboard: "write",
fileSystem: "read-write",
},
customUi: SvgViewerUi,
};