mirror of
https://github.com/AykutSarac/jsoncrack.com.git
synced 2026-06-03 03:24:42 +02:00
feat(chrome-extension): add JSON Crack browser extension
Injects a Raw/Graph toggle on any page served as JSON. Graph mode mounts jsoncrack-react in a full-viewport overlay, with click-to-inspect node details, system-theme detection, a "Powered by JSON Crack" attribution link, and a ToDiagram upgrade overlay when the node limit is exceeded. Built with Vite as an MV3 content script bundle.
This commit is contained in:
@@ -58,6 +58,7 @@ JSON Crack is a tool for visualizing JSON data in a structured, interactive grap
|
||||
## Integrations
|
||||
|
||||
- [VS Code Extension](https://marketplace.visualstudio.com/items?itemName=AykutSarac.jsoncrack-vscode)
|
||||
- [Chrome Extension](./apps/chrome-extension/README.md)
|
||||
- [npm Package (`jsoncrack-react`)](https://www.npmjs.com/package/jsoncrack-react)
|
||||
|
||||
## Contributing
|
||||
@@ -134,6 +135,11 @@ pnpm build:vscode
|
||||
pnpm lint:vscode
|
||||
pnpm lint:fix:vscode
|
||||
|
||||
# Chrome extension
|
||||
pnpm dev:chrome
|
||||
pnpm build:chrome
|
||||
pnpm lint:chrome
|
||||
|
||||
# All workspaces
|
||||
pnpm dev
|
||||
pnpm build
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,38 @@
|
||||
# JSON Crack Chrome Extension
|
||||
|
||||
Chrome Extension (Manifest v3) that injects a segmented control on JSON-only pages.
|
||||
Parsed mode renders with local `jsoncrack-react` (no iframe).
|
||||
|
||||
- `Raw`: browser's default JSON response view
|
||||
- `Parsed`: JSON Crack graph view
|
||||
|
||||
## Development
|
||||
|
||||
From repository root:
|
||||
|
||||
```sh
|
||||
pnpm --filter chrome-extension dev
|
||||
```
|
||||
|
||||
This runs `vite build --watch` and updates `apps/chrome-extension/dist`.
|
||||
|
||||
## Build
|
||||
|
||||
From repository root:
|
||||
|
||||
```sh
|
||||
pnpm --filter chrome-extension build
|
||||
```
|
||||
|
||||
Then load the extension in Chrome:
|
||||
|
||||
1. Open `chrome://extensions`
|
||||
2. Enable **Developer mode**
|
||||
3. Click **Load unpacked**
|
||||
4. Select `apps/chrome-extension/dist`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open a URL that responds with raw JSON.
|
||||
2. Use the floating `Raw | Parsed` segmented control.
|
||||
3. Press `Esc` to quickly return to `Raw`.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "chrome-extension",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"lint": "tsc --project tsconfig.json --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsoncrack-react": "workspace:*",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.20.0"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "JSON Crack",
|
||||
"short_name": "JSON Crack",
|
||||
"description": "Visualize JSON pages as an interactive graph. Toggle between raw text and an auto-generated diagram.",
|
||||
"version": "0.1.0",
|
||||
"author": "Aykut Saraç",
|
||||
"homepage_url": "https://jsoncrack.com",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "JSON Crack",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
#jsoncrack-toggle,
|
||||
#jsoncrack-graph-overlay,
|
||||
#jsoncrack-node-modal-backdrop {
|
||||
--jsoncrack-font:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--jsoncrack-font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
#jsoncrack-toggle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2147483647;
|
||||
font-family: var(--jsoncrack-font);
|
||||
}
|
||||
|
||||
#jsoncrack-toggle .jsoncrack-segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid #a8a8a8;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
#jsoncrack-toggle .jsoncrack-toggle-btn {
|
||||
border: 0;
|
||||
border-right: 1px solid #b4b4b4;
|
||||
background: linear-gradient(180deg, #f2f2f2 0%, #dadada 100%);
|
||||
color: #383838;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
#jsoncrack-toggle .jsoncrack-toggle-btn:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
#jsoncrack-toggle .jsoncrack-toggle-btn.is-active {
|
||||
background: linear-gradient(180deg, #dcdcdc 0%, #c6c6c6 100%);
|
||||
color: #232323;
|
||||
}
|
||||
|
||||
#jsoncrack-graph-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
background: #ffffff;
|
||||
font-family: var(--jsoncrack-font);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#jsoncrack-graph-overlay {
|
||||
background: #0b0f17;
|
||||
}
|
||||
}
|
||||
|
||||
#jsoncrack-graph-overlay[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#jsoncrack-attribution {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2147483647;
|
||||
padding: 8px 16px;
|
||||
background: #0b0f17;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
font-family: var(--jsoncrack-font);
|
||||
}
|
||||
|
||||
#jsoncrack-attribution:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#jsoncrack-upgrade-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(17, 17, 17, 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
font-family: var(--jsoncrack-font);
|
||||
}
|
||||
|
||||
#jsoncrack-upgrade-card {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 460px;
|
||||
padding: 40px 48px;
|
||||
border-radius: 16px;
|
||||
background: rgba(37, 38, 43, 0.97);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.4),
|
||||
0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
color: #f1f3f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
animation: jsoncrack-upgrade-fade-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes jsoncrack-upgrade-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.jsoncrack-upgrade-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.jsoncrack-upgrade-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(18, 184, 134, 0.15);
|
||||
color: #0ca678;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.jsoncrack-upgrade-title {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jsoncrack-upgrade-desc {
|
||||
margin: 0;
|
||||
max-width: 360px;
|
||||
color: #adb5bd;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jsoncrack-upgrade-link {
|
||||
color: #20c997;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.jsoncrack-upgrade-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.jsoncrack-upgrade-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: #0ca678;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.jsoncrack-upgrade-cta:hover {
|
||||
background: #099268;
|
||||
}
|
||||
|
||||
#jsoncrack-react-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#jsoncrack-graph-error {
|
||||
margin: 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #7f1d1d;
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
color: #fee2e2;
|
||||
font: 500 14px/1.4 var(--jsoncrack-font);
|
||||
}
|
||||
|
||||
#jsoncrack-graph-status {
|
||||
margin: 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #1e3a8a;
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
color: #dbeafe;
|
||||
font: 500 14px/1.4 var(--jsoncrack-font);
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
background: rgba(1, 6, 15, 0.66);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal {
|
||||
width: min(680px, calc(100vw - 32px));
|
||||
max-height: min(80vh, 760px);
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||
font-family: var(--jsoncrack-font);
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal .jsoncrack-node-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal .jsoncrack-node-modal-close {
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
background: #111827;
|
||||
color: #cbd5e1;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal .jsoncrack-node-modal-close:hover {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal .jsoncrack-node-modal-section {
|
||||
padding: 12px 14px 14px;
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal .jsoncrack-node-modal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
color: #bfdbfe;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal .jsoncrack-node-modal-copy {
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
background: #111827;
|
||||
color: #dbeafe;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal .jsoncrack-node-modal-copy:hover {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
#jsoncrack-node-modal pre {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
color: #dbeafe;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: 500 12px/1.45 var(--jsoncrack-font-mono);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import type { JSONCrackProps, NodeData } from "jsoncrack-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import jsoncrackStyles from "jsoncrack-react/style.css?inline";
|
||||
import localStyles from "./content-script.css?inline";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
type JSONCrackComponentType = (props: JSONCrackProps) => ReactNode;
|
||||
const NODE_MODAL_BACKDROP_ID = "jsoncrack-node-modal-backdrop";
|
||||
const GRAPH_OVERLAY_ID = "jsoncrack-graph-overlay";
|
||||
const TOGGLE_ID = "jsoncrack-toggle";
|
||||
|
||||
let jsonCrackComponentPromise: Promise<JSONCrackComponentType> | null = null;
|
||||
|
||||
const loadJsonCrackComponent = async (): Promise<JSONCrackComponentType> => {
|
||||
if (jsonCrackComponentPromise) {
|
||||
return jsonCrackComponentPromise;
|
||||
}
|
||||
|
||||
jsonCrackComponentPromise = (async () => {
|
||||
// ELK (the layout engine used by reaflow) tries to spawn a Web Worker
|
||||
// on first use. On JSON pages with a strict `default-src 'none'` CSP,
|
||||
// worker creation throws. Temporarily shadow `Worker` for the duration
|
||||
// of the import + initial layout so ELK falls back to its sync path,
|
||||
// then restore it so the host page's own workers keep working.
|
||||
const originalWorker = (globalThis as { Worker?: typeof Worker }).Worker;
|
||||
try {
|
||||
(globalThis as { Worker?: typeof Worker }).Worker = undefined;
|
||||
const mod = await import("jsoncrack-react");
|
||||
return mod.JSONCrack as JSONCrackComponentType;
|
||||
} finally {
|
||||
(globalThis as { Worker?: typeof Worker }).Worker = originalWorker;
|
||||
}
|
||||
})();
|
||||
|
||||
return jsonCrackComponentPromise;
|
||||
};
|
||||
|
||||
(() => {
|
||||
if (window.top !== window) return;
|
||||
if (!document.body) return;
|
||||
if (document.getElementById(TOGGLE_ID)) return;
|
||||
|
||||
const source = getJsonSource();
|
||||
if (!source) return;
|
||||
|
||||
injectStyles();
|
||||
injectToggle(source);
|
||||
})();
|
||||
|
||||
function injectStyles() {
|
||||
const styleTag = document.createElement("style");
|
||||
styleTag.id = "jsoncrack-inline-style";
|
||||
styleTag.textContent = `${jsoncrackStyles}\n${localStyles}`;
|
||||
document.documentElement.appendChild(styleTag);
|
||||
}
|
||||
|
||||
function getJsonSource() {
|
||||
const body = document.body;
|
||||
const contentType = (document.contentType || "").toLowerCase();
|
||||
|
||||
const pickText = (): string => {
|
||||
if (contentType.includes("json")) {
|
||||
return body.innerText || body.textContent || "";
|
||||
}
|
||||
// Browsers render standalone JSON responses as a single <pre> inside
|
||||
// <body>. Some built-in viewers wrap that <pre> in extra containers,
|
||||
// so look for exactly one <pre> anywhere in the body.
|
||||
const pres = body.querySelectorAll("pre");
|
||||
if (pres.length === 1) {
|
||||
return pres[0].textContent || "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const raw = pickText().trim();
|
||||
if (!raw) return null;
|
||||
if (!startsLikeJson(raw)) return null;
|
||||
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
return raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function startsLikeJson(value: string) {
|
||||
return value.startsWith("{") || value.startsWith("[");
|
||||
}
|
||||
|
||||
function detectTheme(): ThemeMode {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function useSystemTheme(): ThemeMode {
|
||||
const [theme, setTheme] = useState<ThemeMode>(detectTheme);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setTheme(event.matches ? "dark" : "light");
|
||||
};
|
||||
media.addEventListener("change", handler);
|
||||
return () => media.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
function injectToggle(rawJson: string) {
|
||||
const toggleRoot = document.createElement("div");
|
||||
toggleRoot.id = TOGGLE_ID;
|
||||
toggleRoot.innerHTML = [
|
||||
'<div class="jsoncrack-segmented" role="group" aria-label="JSON view mode">',
|
||||
'<button class="jsoncrack-toggle-btn is-active" type="button" data-mode="raw" aria-pressed="true">Raw</button>',
|
||||
'<button class="jsoncrack-toggle-btn" type="button" data-mode="graph" aria-pressed="false">Graph</button>',
|
||||
"</div>",
|
||||
].join("");
|
||||
|
||||
const graphOverlay = document.createElement("div");
|
||||
graphOverlay.id = GRAPH_OVERLAY_ID;
|
||||
graphOverlay.hidden = true;
|
||||
|
||||
const reactRootContainer = document.createElement("div");
|
||||
reactRootContainer.id = "jsoncrack-react-root";
|
||||
graphOverlay.appendChild(reactRootContainer);
|
||||
|
||||
const attribution = document.createElement("a");
|
||||
attribution.id = "jsoncrack-attribution";
|
||||
attribution.href =
|
||||
"https://jsoncrack.com/editor?utm_source=jsoncrack&utm_medium=chrome_extension&utm_campaign=attribution";
|
||||
attribution.target = "_blank";
|
||||
attribution.rel = "noopener noreferrer";
|
||||
attribution.textContent = "Powered by JSON Crack";
|
||||
graphOverlay.appendChild(attribution);
|
||||
|
||||
document.body.appendChild(graphOverlay);
|
||||
document.body.appendChild(toggleRoot);
|
||||
|
||||
const rawButton = toggleRoot.querySelector<HTMLButtonElement>('[data-mode="raw"]');
|
||||
const graphButton = toggleRoot.querySelector<HTMLButtonElement>('[data-mode="graph"]');
|
||||
|
||||
if (!rawButton || !graphButton) return;
|
||||
|
||||
let graphRoot: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
const mountGraphView = () => {
|
||||
if (graphRoot) return;
|
||||
|
||||
graphRoot = createRoot(reactRootContainer);
|
||||
graphRoot.render(<GraphView rawJson={rawJson} />);
|
||||
};
|
||||
|
||||
const unmountGraphView = () => {
|
||||
if (!graphRoot) return;
|
||||
graphRoot.unmount();
|
||||
graphRoot = null;
|
||||
reactRootContainer.textContent = "";
|
||||
};
|
||||
|
||||
const setMode = (mode: "raw" | "graph") => {
|
||||
const isGraph = mode === "graph";
|
||||
|
||||
graphOverlay.hidden = !isGraph;
|
||||
rawButton.classList.toggle("is-active", !isGraph);
|
||||
graphButton.classList.toggle("is-active", isGraph);
|
||||
rawButton.setAttribute("aria-pressed", String(!isGraph));
|
||||
graphButton.setAttribute("aria-pressed", String(isGraph));
|
||||
|
||||
if (isGraph) {
|
||||
// Wait for the overlay to be laid out before mounting so the graph
|
||||
// viewport initializes with the correct container dimensions and
|
||||
// centerOnLayout fits the graph correctly.
|
||||
window.requestAnimationFrame(() => {
|
||||
if (graphOverlay.hidden) return;
|
||||
mountGraphView();
|
||||
});
|
||||
} else {
|
||||
unmountGraphView();
|
||||
}
|
||||
};
|
||||
|
||||
rawButton.addEventListener("click", () => setMode("raw"));
|
||||
graphButton.addEventListener("click", () => setMode("graph"));
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
event => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (graphOverlay.hidden) return;
|
||||
if (document.getElementById(NODE_MODAL_BACKDROP_ID)) return;
|
||||
setMode("raw");
|
||||
},
|
||||
// Use capture so we beat host-page handlers that might stopPropagation.
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const normalizeNodeData = (nodeRows: NodeData["text"]) => {
|
||||
if (!nodeRows || nodeRows.length === 0) return "{}";
|
||||
if (nodeRows.length === 1 && !nodeRows[0].key) return `${nodeRows[0].value}`;
|
||||
|
||||
const obj: Record<string, unknown> = {};
|
||||
nodeRows.forEach(row => {
|
||||
if (row.type !== "array" && row.type !== "object" && row.key) {
|
||||
obj[row.key] = row.value;
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify(obj, null, 2);
|
||||
};
|
||||
|
||||
const jsonPathToString = (path?: NodeData["path"]) => {
|
||||
if (!path || path.length === 0) return "$";
|
||||
const segments = path.map(seg => (typeof seg === "number" ? seg : `"${seg}"`));
|
||||
return `$[${segments.join("][")}]`;
|
||||
};
|
||||
|
||||
function NodeModal({ nodeData, onClose }: { nodeData: NodeData | null; onClose: () => void }) {
|
||||
const [copiedField, setCopiedField] = useState<"content" | "path" | null>(null);
|
||||
const nodeContent = useMemo(() => normalizeNodeData(nodeData?.text ?? []), [nodeData]);
|
||||
const jsonPath = useMemo(() => jsonPathToString(nodeData?.path), [nodeData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeData) return;
|
||||
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onEscape, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onEscape, true);
|
||||
};
|
||||
}, [nodeData, onClose]);
|
||||
|
||||
if (!nodeData) return null;
|
||||
|
||||
const handleCopy = async (field: "content" | "path", text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedField(field);
|
||||
window.setTimeout(() => {
|
||||
setCopiedField(current => (current === field ? null : current));
|
||||
}, 1200);
|
||||
} catch {
|
||||
setCopiedField(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={NODE_MODAL_BACKDROP_ID} onClick={onClose}>
|
||||
<div
|
||||
id="jsoncrack-node-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="jsoncrack-node-modal-header">
|
||||
<strong>Node Content</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="jsoncrack-node-modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="jsoncrack-node-modal-section">
|
||||
<div className="jsoncrack-node-modal-row">
|
||||
<span>Content</span>
|
||||
<button
|
||||
type="button"
|
||||
className="jsoncrack-node-modal-copy"
|
||||
onClick={() => handleCopy("content", nodeContent)}
|
||||
>
|
||||
{copiedField === "content" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre>{nodeContent}</pre>
|
||||
</div>
|
||||
|
||||
<div className="jsoncrack-node-modal-section">
|
||||
<div className="jsoncrack-node-modal-row">
|
||||
<span>JSON Path</span>
|
||||
<button
|
||||
type="button"
|
||||
className="jsoncrack-node-modal-copy"
|
||||
onClick={() => handleCopy("path", jsonPath)}
|
||||
>
|
||||
{copiedField === "path" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre>{jsonPath}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: `rawJson` is captured once at injection time. Single-Page-App JSON
|
||||
// viewers that swap the payload in place without a full reload won't update
|
||||
// the graph. That's acceptable for the current target (browser JSON viewers
|
||||
// on static responses) — revisit if we add SPA support.
|
||||
function GraphView({ rawJson }: { rawJson: string }) {
|
||||
const theme = useSystemTheme();
|
||||
const [JSONCrackComponent, setJSONCrackComponent] = useState<JSONCrackComponentType | null>(null);
|
||||
const [componentError, setComponentError] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
loadJsonCrackComponent()
|
||||
.then(component => {
|
||||
if (!active) return;
|
||||
setJSONCrackComponent(() => component);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
const message = error instanceof Error ? error.message : "Unable to load graph renderer.";
|
||||
setComponentError(message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const parsedJson = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(rawJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [rawJson]);
|
||||
|
||||
if (parsedJson === null) {
|
||||
return <div id="jsoncrack-graph-error">JSON parsing failed for graph mode.</div>;
|
||||
}
|
||||
|
||||
if (componentError) {
|
||||
return <div id="jsoncrack-graph-error">Graph renderer error: {componentError}</div>;
|
||||
}
|
||||
|
||||
if (!JSONCrackComponent) {
|
||||
return <div id="jsoncrack-graph-status">Loading graph renderer...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<JSONCrackComponent
|
||||
json={parsedJson}
|
||||
theme={theme}
|
||||
showControls
|
||||
showGrid
|
||||
centerOnLayout
|
||||
onNodeClick={node => setSelectedNode(node)}
|
||||
renderNodeLimitExceeded={() => <NodeLimitUpgrade />}
|
||||
/>
|
||||
<NodeModal nodeData={selectedNode} onClose={() => setSelectedNode(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeLimitUpgrade() {
|
||||
return (
|
||||
<div id="jsoncrack-upgrade-overlay">
|
||||
<div id="jsoncrack-upgrade-card">
|
||||
<img
|
||||
src="https://todiagram.com/logo.svg"
|
||||
alt="ToDiagram"
|
||||
width={48}
|
||||
height={48}
|
||||
className="jsoncrack-upgrade-logo"
|
||||
/>
|
||||
<span className="jsoncrack-upgrade-badge">Upgrade Required</span>
|
||||
<h2 className="jsoncrack-upgrade-title">Your diagram is too large</h2>
|
||||
<p className="jsoncrack-upgrade-desc">
|
||||
JSON Crack can't render this file.{" "}
|
||||
<a
|
||||
href="https://todiagram.com/editor?utm_source=jsoncrack&utm_medium=chrome_extension&utm_campaign=data_limit"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="jsoncrack-upgrade-link"
|
||||
>
|
||||
ToDiagram
|
||||
</a>{" "}
|
||||
handles large datasets with ease.
|
||||
</p>
|
||||
<a
|
||||
href="https://todiagram.com/editor?utm_source=jsoncrack&utm_medium=chrome_extension&utm_campaign=data_limit&modal=upgrade&format=json&example=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="jsoncrack-upgrade-cta"
|
||||
>
|
||||
Continue with ToDiagram →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.css?inline" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"noImplicitAny": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, "src/content-script.tsx"),
|
||||
formats: ["iife"],
|
||||
name: "JSONCrackContentScript",
|
||||
fileName: () => "content-script.js",
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
banner:
|
||||
"var Worker = undefined; var process = globalThis.process || (globalThis.process = { env: { NODE_ENV: 'production' } });",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -14,14 +14,18 @@
|
||||
"dev": "turbo run dev",
|
||||
"dev:www": "turbo run dev --filter=www",
|
||||
"dev:vscode": "turbo run watch --filter=vscode",
|
||||
"dev:chrome": "turbo run dev --filter=chrome-extension",
|
||||
"build": "turbo run build",
|
||||
"build:www": "turbo run build --filter=www",
|
||||
"build:vscode": "turbo run build --filter=vscode",
|
||||
"build:chrome": "turbo run build --filter=chrome-extension",
|
||||
"start": "turbo run start",
|
||||
"lint": "turbo run lint",
|
||||
"lint:vscode": "turbo run lint --filter=vscode",
|
||||
"lint:chrome": "turbo run lint --filter=chrome-extension",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"lint:fix:vscode": "turbo run lint:fix --filter=vscode",
|
||||
"test": "turbo run test",
|
||||
"analyze": "turbo run analyze",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
|
||||
Generated
+590
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user