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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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>
|
<title>Plimi</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { csvToolsPlugin } from "../../tools/csv-tools";
|
|||||||
import { qrCodeGeneratorPlugin } from "../../tools/qr-code-generator";
|
import { qrCodeGeneratorPlugin } from "../../tools/qr-code-generator";
|
||||||
import { passwordGeneratorPlugin } from "../../tools/password-generator";
|
import { passwordGeneratorPlugin } from "../../tools/password-generator";
|
||||||
import { fileChecksumVerifierPlugin } from "../../tools/file-checksum-verifier";
|
import { fileChecksumVerifierPlugin } from "../../tools/file-checksum-verifier";
|
||||||
|
import { svgViewerPlugin } from "../../tools/svg-viewer";
|
||||||
|
|
||||||
function erasePlugin<TOptions>(plugin: PlimiPlugin<TOptions>): UnknownPlimiPlugin {
|
function erasePlugin<TOptions>(plugin: PlimiPlugin<TOptions>): UnknownPlimiPlugin {
|
||||||
return plugin as unknown as UnknownPlimiPlugin;
|
return plugin as unknown as UnknownPlimiPlugin;
|
||||||
@@ -57,6 +58,7 @@ export const pluginRegistry: UnknownPlimiPlugin[] = [
|
|||||||
erasePlugin(qrCodeGeneratorPlugin),
|
erasePlugin(qrCodeGeneratorPlugin),
|
||||||
erasePlugin(passwordGeneratorPlugin),
|
erasePlugin(passwordGeneratorPlugin),
|
||||||
erasePlugin(fileChecksumVerifierPlugin),
|
erasePlugin(fileChecksumVerifierPlugin),
|
||||||
|
erasePlugin(svgViewerPlugin),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getAllPlugins(): UnknownPlimiPlugin[] {
|
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