Add SVG viewer and Umami analytics
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script defer src="https://u.achraf.app/script.js" data-website-id="89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"></script>
|
||||
<title>Plimi</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
323
src/tools/svg-viewer/SvgViewerUi.tsx
Normal file
323
src/tools/svg-viewer/SvgViewerUi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/tools/svg-viewer/index.ts
Normal file
38
src/tools/svg-viewer/index.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user