mirror of
https://github.com/alexandrev/xslt-lab.git
synced 2026-09-16 18:23:16 +00:00
a333d175f4
- Update index.html title, description, keywords to include xslt 3.0, validator, saxon - Update JSON-LD schema with full feature list and XSLT 3.0 - Change footer link from "News" to "Blog & Tutorials", update URL to blog.xsltplayground.com - New articles: online editor guide, validator guide, string functions, xsl:for-each-group Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1982 lines
65 KiB
React
1982 lines
65 KiB
React
import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react";
|
|
import logo from "./logo.svg";
|
|
import TabsNav from "./components/TabsNav";
|
|
import DataPipelineHeader from "./components/DataPipelineHeader";
|
|
import Icon from "./components/Icon";
|
|
import {
|
|
parseErrorLines,
|
|
stripParamBlock,
|
|
injectParamBlock,
|
|
addParams,
|
|
extractParamNames,
|
|
setStylesheetVersion,
|
|
} from "./lib/workspaceUtils";
|
|
|
|
/* global __APP_VERSION__ */
|
|
|
|
const MonacoEditor = lazy(() => import("@monaco-editor/react"));
|
|
const FeedbackWidget = lazy(() => import("./components/FeedbackWidget"));
|
|
const BuyMeACoffee = lazy(() => import("./components/BuyMeACoffee"));
|
|
|
|
function runWhenIdle(callback, timeout = 2000) {
|
|
if (typeof window === "undefined") {
|
|
return () => {};
|
|
}
|
|
if ("requestIdleCallback" in window) {
|
|
const id = window.requestIdleCallback(callback, { timeout });
|
|
return () => window.cancelIdleCallback?.(id);
|
|
}
|
|
const id = window.setTimeout(callback, timeout);
|
|
return () => window.clearTimeout(id);
|
|
}
|
|
|
|
function Editor({ height, ...props }) {
|
|
const fallbackStyle = height ? { height } : undefined;
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<div
|
|
className="editor-loading"
|
|
role="status"
|
|
aria-live="polite"
|
|
style={fallbackStyle}
|
|
>
|
|
Loading editor...
|
|
</div>
|
|
}
|
|
>
|
|
<MonacoEditor height={height} {...props} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function debounce(fn, delay) {
|
|
let t;
|
|
return (...args) => {
|
|
clearTimeout(t);
|
|
t = setTimeout(() => fn(...args), delay);
|
|
};
|
|
}
|
|
|
|
const env = window.env || import.meta.env;
|
|
const goPro = env.VITE_GO_PRO === "true";
|
|
const adsenseClient = env.VITE_ADSENSE_CLIENT;
|
|
const adsenseSlot = env.VITE_ADSENSE_SLOT;
|
|
const ethicalAdsPublisher = env.VITE_ETHICALADS_PUBLISHER || "xsltplaygroundcom";
|
|
const defaultRepoUrl = "https://github.com/alexandrev/xslt-lab";
|
|
const repoUrl = env.VITE_REPO_URL || defaultRepoUrl;
|
|
const newsUrl = env.VITE_NEWS_URL || "https://blog.xsltplayground.com/";
|
|
const resolvedVersion =
|
|
typeof __APP_VERSION__ !== "undefined" && __APP_VERSION__
|
|
? __APP_VERSION__
|
|
: env.VITE_APP_VERSION || "";
|
|
const changelogUrl = `${repoUrl}/blob/main/CHANGELOG.md${
|
|
resolvedVersion ? `#${changelogAnchor(resolvedVersion)}` : ""
|
|
}`;
|
|
|
|
function defaultTab(overrides = {}) {
|
|
const base = {
|
|
id: Date.now() + Math.random(),
|
|
params: [{ name: "input1", value: "<root/>", open: true }],
|
|
xslt: `<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">\n<xsl:template match="/">\n<root/>\n</xsl:template>\n</xsl:stylesheet>`,
|
|
version: "1.0",
|
|
name: "",
|
|
};
|
|
const merged = { ...base, ...overrides };
|
|
return {
|
|
...merged,
|
|
id: overrides.id ?? base.id,
|
|
params: Array.isArray(merged.params) ? merged.params : base.params,
|
|
xslt: typeof merged.xslt === "string" ? merged.xslt : base.xslt,
|
|
version: merged.version || base.version,
|
|
name: typeof merged.name === "string" ? merged.name : base.name,
|
|
};
|
|
}
|
|
|
|
const MAX_WORKSPACES = 3;
|
|
const WORKSPACE_EXPORT_VERSION = 1;
|
|
const RESULT_HEIGHT_KEY = "resultPaneHeight";
|
|
const DEFAULT_RESULT_RATIO = 0.4;
|
|
const MIN_RESULT_HEIGHT = 180;
|
|
const MIN_MAIN_HEIGHT = 320;
|
|
const PARAM_WIDTH_KEY = "paramsPaneWidth";
|
|
const DEFAULT_PARAM_WIDTH = 320;
|
|
const MIN_PARAM_WIDTH = 220;
|
|
const MIN_EDITOR_WIDTH = 360;
|
|
const ETHICAL_AD_COMPACT_BREAKPOINT = 1024;
|
|
const ETHICAL_AD_TEXT_BREAKPOINT = 720;
|
|
const THEME_STORAGE_KEY = "themeMode";
|
|
const THEME_DARK = "dark";
|
|
const THEME_LIGHT = "light";
|
|
|
|
function defaultWorkspaceStatus() {
|
|
return {
|
|
result: "",
|
|
duration: null,
|
|
error: "",
|
|
errorLines: [],
|
|
traceEntries: [],
|
|
traceText: "",
|
|
showRawTrace: false,
|
|
resultView: "source",
|
|
};
|
|
}
|
|
|
|
function looksLikeHtml(text) {
|
|
if (!text || typeof text !== "string") return false;
|
|
const trimmed = text.trim();
|
|
if (!trimmed.startsWith("<")) return false;
|
|
const lowered = trimmed.slice(0, 200).toLowerCase();
|
|
return (
|
|
lowered.startsWith("<!doctype html") ||
|
|
/^<html\b/.test(lowered) ||
|
|
/^<body\b/.test(lowered)
|
|
);
|
|
}
|
|
|
|
function normalizeWorkspaceImport(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
throw new Error("Workspace file is empty or invalid.");
|
|
}
|
|
if (
|
|
payload.schemaVersion &&
|
|
payload.schemaVersion !== WORKSPACE_EXPORT_VERSION
|
|
) {
|
|
throw new Error(
|
|
`Unsupported workspace format version ${payload.schemaVersion}.`,
|
|
);
|
|
}
|
|
const workspace = payload.workspace || payload;
|
|
if (!workspace.xslt || typeof workspace.xslt !== "string") {
|
|
throw new Error("Workspace file is missing the XSLT content.");
|
|
}
|
|
const baseParams = defaultTab().params;
|
|
const params = Array.isArray(workspace.params)
|
|
? workspace.params.map((p, idx) => ({
|
|
name: typeof p?.name === "string" ? p.name : `param${idx + 1}`,
|
|
value: typeof p?.value === "string" ? p.value : "",
|
|
open: Boolean(p?.open),
|
|
}))
|
|
: baseParams;
|
|
const status =
|
|
payload.status && typeof payload.status === "object"
|
|
? { ...defaultWorkspaceStatus(), ...payload.status }
|
|
: defaultWorkspaceStatus();
|
|
return {
|
|
workspace: {
|
|
params,
|
|
xslt: workspace.xslt,
|
|
version: workspace.version || "1.0",
|
|
},
|
|
status,
|
|
};
|
|
}
|
|
|
|
function changelogAnchor(version) {
|
|
if (!version) return "";
|
|
return `v${version}`.replace(/[^0-9a-zA-Z]+/g, "").toLowerCase();
|
|
}
|
|
|
|
export default function App() {
|
|
// Load persisted workspace from localStorage (if present)
|
|
let initialTabs = [defaultTab()];
|
|
try {
|
|
const stored = localStorage.getItem("tabs");
|
|
if (stored) initialTabs = JSON.parse(stored);
|
|
} catch {}
|
|
if (!Array.isArray(initialTabs)) {
|
|
initialTabs = [defaultTab()];
|
|
}
|
|
initialTabs = initialTabs.map((tab) => {
|
|
if (!tab || typeof tab !== "object") return defaultTab();
|
|
return defaultTab({
|
|
id: tab.id,
|
|
params: tab.params,
|
|
xslt: tab.xslt,
|
|
version: tab.version,
|
|
name: tab.name,
|
|
});
|
|
});
|
|
if (initialTabs.length > MAX_WORKSPACES) {
|
|
initialTabs = initialTabs.slice(0, MAX_WORKSPACES);
|
|
}
|
|
let initialActive = initialTabs[0]?.id;
|
|
try {
|
|
const sAct = localStorage.getItem("active");
|
|
if (sAct) initialActive = JSON.parse(sAct);
|
|
} catch {}
|
|
|
|
const readStoredWorkspaceStatus = () => {
|
|
try {
|
|
const stored = localStorage.getItem("workspaceStatus");
|
|
if (stored) {
|
|
return JSON.parse(stored);
|
|
}
|
|
} catch {}
|
|
return {};
|
|
};
|
|
|
|
const [tabs, setTabs] = useState(initialTabs);
|
|
const [active, setActive] = useState(initialActive);
|
|
const [workspaceStatus, setWorkspaceStatus] = useState(() => {
|
|
const stored = readStoredWorkspaceStatus();
|
|
const initialStatus = {};
|
|
initialTabs.forEach((tab) => {
|
|
initialStatus[tab.id] = stored[tab.id]
|
|
? { ...defaultWorkspaceStatus(), ...stored[tab.id] }
|
|
: defaultWorkspaceStatus();
|
|
});
|
|
return initialStatus;
|
|
});
|
|
const [editorFocused, setEditorFocused] = useState(false);
|
|
const [user, setUser] = useState(null);
|
|
const [auth, setAuth] = useState(null);
|
|
const [theme, setTheme] = useState(() => {
|
|
if (typeof window === "undefined") return THEME_LIGHT;
|
|
try {
|
|
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
if (stored === THEME_DARK || stored === THEME_LIGHT) return stored;
|
|
} catch {}
|
|
try {
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? THEME_DARK
|
|
: THEME_LIGHT;
|
|
} catch {}
|
|
return THEME_LIGHT;
|
|
});
|
|
const isDarkTheme = theme === THEME_DARK;
|
|
const editorTheme = isDarkTheme ? "vs-dark" : "light";
|
|
const resultEditorRef = useRef(null);
|
|
const traceHoverTimeoutRef = useRef(null);
|
|
const traceTableWrapRef = useRef(null);
|
|
const traceNameRefs = useRef([]);
|
|
const tabsRef = useRef(tabs);
|
|
const [traceEnabled, setTraceEnabled] = useState(() => {
|
|
try { return JSON.parse(localStorage.getItem("traceEnabled") || "false"); } catch { return false; }
|
|
});
|
|
const [traceCollapsed, setTraceCollapsed] = useState(false);
|
|
const [traceHover, setTraceHover] = useState(null);
|
|
const [traceNameWidth, setTraceNameWidth] = useState(240);
|
|
const [traceScrollLeft, setTraceScrollLeft] = useState(0);
|
|
const [paramsCollapsed, setParamsCollapsed] = useState(false);
|
|
const [errorCollapsed, setErrorCollapsed] = useState(false);
|
|
const [ethicalAdsReady, setEthicalAdsReady] = useState(false);
|
|
const workspaceImportRef = useRef(null);
|
|
const resultResizeState = useRef({ startY: 0, startHeight: MIN_RESULT_HEIGHT });
|
|
const paramResizeState = useRef({ startX: 0, startWidth: DEFAULT_PARAM_WIDTH });
|
|
const [resultHeight, setResultHeight] = useState(() => {
|
|
try {
|
|
const stored = localStorage.getItem(RESULT_HEIGHT_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (typeof parsed === "number" && Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
} catch {}
|
|
return null;
|
|
});
|
|
const [paramWidth, setParamWidth] = useState(() => {
|
|
if (typeof window === "undefined") return DEFAULT_PARAM_WIDTH;
|
|
try {
|
|
const stored = localStorage.getItem(PARAM_WIDTH_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (typeof parsed === "number" && Number.isFinite(parsed)) {
|
|
return Math.max(parsed, MIN_PARAM_WIDTH);
|
|
}
|
|
}
|
|
} catch {}
|
|
return DEFAULT_PARAM_WIDTH;
|
|
});
|
|
const [isResizingResult, setIsResizingResult] = useState(false);
|
|
const [isResizingParams, setIsResizingParams] = useState(false);
|
|
const [viewportHeight, setViewportHeight] = useState(() =>
|
|
typeof window !== "undefined" ? window.innerHeight : 0,
|
|
);
|
|
const [viewportWidth, setViewportWidth] = useState(() =>
|
|
typeof window !== "undefined" ? window.innerWidth : 0,
|
|
);
|
|
const [userInteracted, setUserInteracted] = useState(false);
|
|
const [widgetsReady, setWidgetsReady] = useState(false);
|
|
const [autoRunReady, setAutoRunReady] = useState(false);
|
|
const ethicalSlotRef = useRef(null);
|
|
const isLocalhost =
|
|
typeof window !== "undefined" &&
|
|
/^(localhost|127(?:\\.[0-9]+){3}|mac)$/i.test(window.location.hostname);
|
|
const ethicalAdsEnabled =
|
|
Boolean(ethicalAdsPublisher) &&
|
|
(!isLocalhost || env.VITE_ETHICALADS_DEV === "true");
|
|
const isCompactEthicalAd =
|
|
!viewportWidth || viewportWidth < ETHICAL_AD_COMPACT_BREAKPOINT;
|
|
const ethicalAdType =
|
|
viewportWidth && viewportWidth < ETHICAL_AD_TEXT_BREAKPOINT
|
|
? "text"
|
|
: "image";
|
|
const ethicalAdStyle = isCompactEthicalAd ? undefined : "fixedheader";
|
|
const ethicalAdHeight = isCompactEthicalAd ? "80px" : "50px";
|
|
const ethicalAdVariant = `${isCompactEthicalAd ? "compact" : "wide"}-${ethicalAdType}`;
|
|
|
|
const backendBase = (env.VITE_BACKEND_URL || "").replace(/\/$/, "");
|
|
|
|
useEffect(() => {
|
|
const gaId = env.VITE_GA_ID;
|
|
if (!gaId || !userInteracted) return;
|
|
let cancelled = false;
|
|
const cancelIdle = runWhenIdle(async () => {
|
|
if (cancelled) return;
|
|
try {
|
|
const { default: GA4React } = await import("ga-4-react");
|
|
if (cancelled) return;
|
|
const ga4react = new GA4React(gaId);
|
|
ga4react.initialize().catch((err) => console.error(err));
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}, 2500);
|
|
return () => {
|
|
cancelled = true;
|
|
cancelIdle?.();
|
|
};
|
|
}, [userInteracted]);
|
|
|
|
useEffect(() => {
|
|
const enable = () => {
|
|
setUserInteracted(true);
|
|
setAutoRunReady(true);
|
|
setWidgetsReady(true);
|
|
};
|
|
window.addEventListener("pointerdown", enable, { once: true });
|
|
window.addEventListener("keydown", enable, { once: true });
|
|
return () => {
|
|
window.removeEventListener("pointerdown", enable);
|
|
window.removeEventListener("keydown", enable);
|
|
};
|
|
}, []);
|
|
|
|
// Persist workspace on change
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem("tabs", JSON.stringify(tabs));
|
|
localStorage.setItem("active", JSON.stringify(active));
|
|
} catch {}
|
|
}, [tabs, active]);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem("workspaceStatus", JSON.stringify(workspaceStatus));
|
|
} catch {}
|
|
}, [workspaceStatus]);
|
|
|
|
useEffect(() => {
|
|
try { localStorage.setItem("traceEnabled", JSON.stringify(traceEnabled)); } catch {}
|
|
}, [traceEnabled]);
|
|
|
|
useEffect(() => {
|
|
if (typeof document === "undefined") return;
|
|
document.documentElement.dataset.theme = theme;
|
|
try {
|
|
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
} catch {}
|
|
}, [theme]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
const handleResize = () => {
|
|
setViewportHeight(window.innerHeight);
|
|
setViewportWidth(window.innerWidth);
|
|
};
|
|
window.addEventListener("resize", handleResize);
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
if (resultHeight === null) {
|
|
localStorage.removeItem(RESULT_HEIGHT_KEY);
|
|
} else {
|
|
localStorage.setItem(RESULT_HEIGHT_KEY, JSON.stringify(resultHeight));
|
|
}
|
|
} catch {}
|
|
}, [resultHeight]);
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem(PARAM_WIDTH_KEY, JSON.stringify(paramWidth));
|
|
} catch {}
|
|
}, [paramWidth]);
|
|
|
|
useEffect(() => {
|
|
if (traceCollapsed) {
|
|
setTraceHover(null);
|
|
setTraceScrollLeft(0);
|
|
}
|
|
}, [traceCollapsed]);
|
|
|
|
useEffect(() => {
|
|
setWorkspaceStatus((prev) => {
|
|
let changed = false;
|
|
const next = {};
|
|
tabs.forEach((tab) => {
|
|
if (prev[tab.id]) {
|
|
next[tab.id] = prev[tab.id];
|
|
} else {
|
|
next[tab.id] = defaultWorkspaceStatus();
|
|
changed = true;
|
|
}
|
|
});
|
|
if (Object.keys(prev).length !== tabs.length) {
|
|
changed = true;
|
|
}
|
|
return changed ? next : prev;
|
|
});
|
|
}, [tabs]);
|
|
|
|
useEffect(() => {
|
|
if (tabs.length === 0) {
|
|
return;
|
|
}
|
|
if (!tabs.some((t) => t.id === active)) {
|
|
setActive(tabs[0].id);
|
|
}
|
|
}, [tabs, active]);
|
|
|
|
useEffect(() => {
|
|
if (!traceEnabled) {
|
|
setTraceHover(null);
|
|
setTraceCollapsed(false);
|
|
}
|
|
}, [traceEnabled]);
|
|
|
|
useEffect(() => {
|
|
const closeOnChange = () => setTraceHover(null);
|
|
window.addEventListener("scroll", closeOnChange, true);
|
|
window.addEventListener("resize", closeOnChange);
|
|
return () => {
|
|
window.removeEventListener("scroll", closeOnChange, true);
|
|
window.removeEventListener("resize", closeOnChange);
|
|
};
|
|
}, []);
|
|
|
|
const activeTab = tabs.find((t) => t.id === active) || tabs[0];
|
|
const activeStatus = activeTab
|
|
? (workspaceStatus[activeTab.id] || defaultWorkspaceStatus())
|
|
: defaultWorkspaceStatus();
|
|
const {
|
|
result,
|
|
duration,
|
|
error,
|
|
errorLines,
|
|
traceEntries,
|
|
traceText,
|
|
showRawTrace,
|
|
resultView,
|
|
} = activeStatus;
|
|
const MAX_ERROR_LINES = 3;
|
|
const limitedErrorLines = (errorLines || []).slice(0, MAX_ERROR_LINES);
|
|
const hasHiddenErrors = (errorLines || []).length > MAX_ERROR_LINES;
|
|
const canCopyErrors = Boolean((errorLines && errorLines.length) || error);
|
|
const showResultPane = !error;
|
|
const canRenderHtml = useMemo(() => looksLikeHtml(result), [result]);
|
|
const effectiveResultView = canRenderHtml ? resultView || "source" : "source";
|
|
const TRACE_NAME_LIMIT = 80;
|
|
const TRACE_VALUE_LIMIT = 200;
|
|
const EMPTY_SYMBOL = "(empty)";
|
|
const TRACE_NAME_MIN_WIDTH = 120;
|
|
const TRACE_NAME_PADDING = 40;
|
|
const clampResultPaneHeight = useCallback(
|
|
(value) => {
|
|
const numericValue =
|
|
typeof value === "number" && Number.isFinite(value)
|
|
? value
|
|
: MIN_RESULT_HEIGHT;
|
|
if (!viewportHeight) {
|
|
return Math.max(MIN_RESULT_HEIGHT, Math.round(numericValue));
|
|
}
|
|
const maxHeight = Math.max(
|
|
MIN_RESULT_HEIGHT,
|
|
viewportHeight - MIN_MAIN_HEIGHT,
|
|
);
|
|
return Math.min(
|
|
Math.max(Math.round(numericValue), MIN_RESULT_HEIGHT),
|
|
maxHeight,
|
|
);
|
|
},
|
|
[viewportHeight],
|
|
);
|
|
const clampParamPaneWidth = useCallback(
|
|
(value) => {
|
|
const numericValue =
|
|
typeof value === "number" && Number.isFinite(value)
|
|
? value
|
|
: DEFAULT_PARAM_WIDTH;
|
|
const viewportLimit =
|
|
viewportWidth && viewportWidth > 0
|
|
? Math.max(MIN_PARAM_WIDTH, viewportWidth - MIN_EDITOR_WIDTH)
|
|
: Number.POSITIVE_INFINITY;
|
|
const maxWidth = Number.isFinite(viewportLimit)
|
|
? viewportLimit
|
|
: Math.max(MIN_PARAM_WIDTH, numericValue);
|
|
return Math.min(
|
|
Math.max(Math.round(numericValue), MIN_PARAM_WIDTH),
|
|
maxWidth,
|
|
);
|
|
},
|
|
[viewportWidth],
|
|
);
|
|
const fallbackViewportHeight = viewportHeight || 800;
|
|
const resolvedResultHeight = useMemo(
|
|
() =>
|
|
clampResultPaneHeight(
|
|
resultHeight ??
|
|
Math.round(fallbackViewportHeight * DEFAULT_RESULT_RATIO),
|
|
),
|
|
[resultHeight, fallbackViewportHeight, clampResultPaneHeight],
|
|
);
|
|
const isCustomResultHeight = resultHeight !== null;
|
|
useEffect(() => {
|
|
setParamWidth((prev) => clampParamPaneWidth(prev));
|
|
}, [clampParamPaneWidth]);
|
|
|
|
useEffect(() => {
|
|
if (!error) {
|
|
setErrorCollapsed(false);
|
|
}
|
|
}, [error]);
|
|
|
|
const clampTraceNameWidth = useCallback(() => {
|
|
const container = traceTableWrapRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
const maxWidth = Math.max(
|
|
TRACE_NAME_MIN_WIDTH,
|
|
container.clientWidth - 160,
|
|
);
|
|
setTraceNameWidth((prev) =>
|
|
Math.min(Math.max(prev, TRACE_NAME_MIN_WIDTH), maxWidth),
|
|
);
|
|
}, []);
|
|
|
|
if (traceNameRefs.current.length !== traceEntries.length) {
|
|
traceNameRefs.current.length = traceEntries.length;
|
|
}
|
|
|
|
useEffect(() => {
|
|
clampTraceNameWidth();
|
|
}, [traceCollapsed, traceEntries.length, clampTraceNameWidth]);
|
|
|
|
useEffect(() => {
|
|
const handleResize = () => clampTraceNameWidth();
|
|
window.addEventListener("resize", handleResize);
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
}, [clampTraceNameWidth]);
|
|
|
|
useEffect(() => {
|
|
setTraceScrollLeft(0);
|
|
}, [traceEntries]);
|
|
|
|
useEffect(() => {
|
|
setResultHeight((prev) => {
|
|
if (prev === null) return prev;
|
|
const clamped = clampResultPaneHeight(prev);
|
|
return clamped === prev ? prev : clamped;
|
|
});
|
|
}, [clampResultPaneHeight]);
|
|
|
|
useEffect(() => {
|
|
if (!isResizingResult) return;
|
|
const handleMove = (event) => {
|
|
event.preventDefault();
|
|
const delta = event.clientY - resultResizeState.current.startY;
|
|
const nextHeight = clampResultPaneHeight(
|
|
resultResizeState.current.startHeight - delta,
|
|
);
|
|
setResultHeight(nextHeight);
|
|
};
|
|
const stop = () => setIsResizingResult(false);
|
|
window.addEventListener("mousemove", handleMove);
|
|
window.addEventListener("mouseup", stop);
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMove);
|
|
window.removeEventListener("mouseup", stop);
|
|
};
|
|
}, [isResizingResult, clampResultPaneHeight]);
|
|
|
|
useEffect(() => {
|
|
if (typeof document === "undefined") return undefined;
|
|
if (!isResizingResult) return undefined;
|
|
const { style } = document.body;
|
|
const previousCursor = style.cursor;
|
|
const previousSelect = style.userSelect;
|
|
style.cursor = "row-resize";
|
|
style.userSelect = "none";
|
|
return () => {
|
|
style.cursor = previousCursor;
|
|
style.userSelect = previousSelect;
|
|
};
|
|
}, [isResizingResult]);
|
|
useEffect(() => {
|
|
if (!isResizingParams) return;
|
|
const handleMove = (event) => {
|
|
event.preventDefault();
|
|
const delta = event.clientX - paramResizeState.current.startX;
|
|
const nextWidth = clampParamPaneWidth(
|
|
paramResizeState.current.startWidth + delta,
|
|
);
|
|
setParamWidth(nextWidth);
|
|
};
|
|
const stop = () => setIsResizingParams(false);
|
|
window.addEventListener("mousemove", handleMove);
|
|
window.addEventListener("mouseup", stop);
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMove);
|
|
window.removeEventListener("mouseup", stop);
|
|
};
|
|
}, [isResizingParams, clampParamPaneWidth]);
|
|
useEffect(() => {
|
|
if (typeof document === "undefined") return undefined;
|
|
if (!isResizingParams) return undefined;
|
|
const { style } = document.body;
|
|
const previousCursor = style.cursor;
|
|
const previousSelect = style.userSelect;
|
|
style.cursor = "col-resize";
|
|
style.userSelect = "none";
|
|
return () => {
|
|
style.cursor = previousCursor;
|
|
style.userSelect = previousSelect;
|
|
};
|
|
}, [isResizingParams]);
|
|
useEffect(() => {
|
|
if (!paramsCollapsed) return;
|
|
setIsResizingParams(false);
|
|
}, [paramsCollapsed]);
|
|
|
|
useEffect(() => {
|
|
if (!goPro) return;
|
|
let cancelled = false;
|
|
const cancelIdle = runWhenIdle(async () => {
|
|
if (cancelled) return;
|
|
try {
|
|
const cfg = env.VITE_FIREBASE_CONFIG;
|
|
if (!cfg) return;
|
|
const [{ initializeApp }, { getAuth }] = await Promise.all([
|
|
import("firebase/app"),
|
|
import("firebase/auth"),
|
|
]);
|
|
if (cancelled) return;
|
|
const app = initializeApp(JSON.parse(cfg));
|
|
const a = getAuth(app);
|
|
setAuth(a);
|
|
a.onAuthStateChanged((u) => setUser(u));
|
|
} catch {}
|
|
}, 2500);
|
|
return () => {
|
|
cancelled = true;
|
|
cancelIdle?.();
|
|
};
|
|
}, [goPro]);
|
|
|
|
useEffect(() => {
|
|
tabsRef.current = tabs;
|
|
}, [tabs]);
|
|
|
|
const updateWorkspaceStatus = useCallback((tabId, updates) => {
|
|
if (!tabId) return;
|
|
if (!tabsRef.current.some((t) => t.id === tabId)) {
|
|
return;
|
|
}
|
|
setWorkspaceStatus((prev) => {
|
|
const current = prev[tabId]
|
|
? { ...defaultWorkspaceStatus(), ...prev[tabId] }
|
|
: defaultWorkspaceStatus();
|
|
const next =
|
|
typeof updates === "function"
|
|
? updates(current)
|
|
: { ...current, ...updates };
|
|
return { ...prev, [tabId]: next };
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!activeTab) return;
|
|
if (!canRenderHtml && resultView === "render") {
|
|
updateWorkspaceStatus(activeTab.id, (prev) => ({
|
|
...prev,
|
|
resultView: "source",
|
|
}));
|
|
}
|
|
}, [activeTab, canRenderHtml, resultView, updateWorkspaceStatus]);
|
|
|
|
const truncateText = useCallback((text, limit) => {
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
}, []);
|
|
|
|
const cancelTraceTooltipHide = useCallback(() => {
|
|
if (traceHoverTimeoutRef.current) {
|
|
clearTimeout(traceHoverTimeoutRef.current);
|
|
traceHoverTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const showTraceTooltip = useCallback((event, text) => {
|
|
cancelTraceTooltipHide();
|
|
const target = event.currentTarget;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
const rect = target.getBoundingClientRect();
|
|
const padding = 16;
|
|
const pointerX = event.clientX;
|
|
const pointerY = event.clientY;
|
|
const desiredWidth = Math.max(rect.width, 280);
|
|
const maxWidth = Math.min(desiredWidth, window.innerWidth - padding * 2);
|
|
const left = Math.min(
|
|
Math.max(pointerX + 12, padding),
|
|
window.innerWidth - maxWidth - padding
|
|
);
|
|
const top = Math.min(pointerY + 16, window.innerHeight - padding - 40);
|
|
setTraceHover({
|
|
text: text || EMPTY_SYMBOL,
|
|
x: left,
|
|
y: top,
|
|
width: maxWidth
|
|
});
|
|
}, [cancelTraceTooltipHide]);
|
|
|
|
const hideTraceTooltip = useCallback(() => {
|
|
cancelTraceTooltipHide();
|
|
traceHoverTimeoutRef.current = window.setTimeout(() => {
|
|
setTraceHover(null);
|
|
traceHoverTimeoutRef.current = null;
|
|
}, 120);
|
|
}, [cancelTraceTooltipHide]);
|
|
|
|
const copyTraceHover = useCallback(() => {
|
|
if (!traceHover?.text) {
|
|
return;
|
|
}
|
|
try {
|
|
navigator.clipboard?.writeText(traceHover.text);
|
|
} catch (err) {
|
|
console.error("Clipboard copy failed", err);
|
|
}
|
|
}, [traceHover]);
|
|
|
|
const copyErrors = useCallback(() => {
|
|
const allLines = (errorLines && errorLines.length ? errorLines : error ? [error] : []);
|
|
const payload = allLines.join("\n").trim();
|
|
if (!payload) return;
|
|
try {
|
|
navigator.clipboard?.writeText(payload);
|
|
} catch (err) {
|
|
console.error("Copy errors failed", err);
|
|
}
|
|
}, [errorLines, error]);
|
|
|
|
const registerTraceNameRef = useCallback(
|
|
(index) => (node) => {
|
|
traceNameRefs.current[index] = node;
|
|
},
|
|
[]
|
|
);
|
|
|
|
const autoSizeTraceNameColumn = useCallback(() => {
|
|
const container = traceTableWrapRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
const maxAllowed = Math.max(TRACE_NAME_MIN_WIDTH, container.clientWidth - 160);
|
|
let maxWidth = TRACE_NAME_MIN_WIDTH;
|
|
traceNameRefs.current.forEach((el) => {
|
|
if (el) {
|
|
maxWidth = Math.max(maxWidth, el.scrollWidth + TRACE_NAME_PADDING);
|
|
}
|
|
});
|
|
setTraceNameWidth(Math.min(Math.max(maxWidth, TRACE_NAME_MIN_WIDTH), maxAllowed));
|
|
}, []);
|
|
|
|
const handleTraceDividerMouseDown = useCallback((event) => {
|
|
if (event.detail === 2) {
|
|
event.preventDefault();
|
|
autoSizeTraceNameColumn();
|
|
return;
|
|
}
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const startX = event.clientX;
|
|
const startWidth = traceNameWidth;
|
|
const container = traceTableWrapRef.current;
|
|
const maxAllowed = container ? Math.max(TRACE_NAME_MIN_WIDTH, container.clientWidth - 160) : startWidth + 400;
|
|
|
|
const onMove = (moveEvent) => {
|
|
const delta = moveEvent.clientX - startX;
|
|
const newWidth = Math.min(Math.max(startWidth + delta, TRACE_NAME_MIN_WIDTH), maxAllowed);
|
|
setTraceNameWidth(newWidth);
|
|
};
|
|
const onUp = () => {
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
};
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
}, [autoSizeTraceNameColumn, traceNameWidth]);
|
|
|
|
const handleCopyAllTrace = useCallback(() => {
|
|
const chunks = traceEntries.map((entry) => {
|
|
const name = (entry?.name ?? "").toString();
|
|
const value = (entry?.value ?? "").toString();
|
|
return `${name}\n${value}`.trim();
|
|
}).filter(Boolean);
|
|
let combined = chunks.join("\n\n");
|
|
if (showRawTrace && traceText) {
|
|
combined = combined
|
|
? `${combined}\n\n--- Raw Trace ---\n${traceText}`
|
|
: traceText;
|
|
}
|
|
if (!combined && traceText) {
|
|
combined = traceText;
|
|
}
|
|
if (!combined) {
|
|
return;
|
|
}
|
|
try {
|
|
navigator.clipboard?.writeText(combined);
|
|
} catch (err) {
|
|
console.error("Copy all trace failed", err);
|
|
}
|
|
}, [traceEntries, traceText, showRawTrace]);
|
|
|
|
const syncParams = useCallback(() => {
|
|
const names = extractParamNames(injectParamBlock(activeTab.xslt, activeTab.params));
|
|
setTabs((tabs) =>
|
|
tabs.map((t) => {
|
|
if (t.id !== active) return t;
|
|
let params = [...t.params];
|
|
let changed = false;
|
|
names.forEach((n) => {
|
|
if (!params.some((p) => p.name === n)) {
|
|
params.push({ name: n, value: "", open: false });
|
|
changed = true;
|
|
}
|
|
});
|
|
const filtered = params.filter((p) => names.includes(p.name) || p.value);
|
|
if (filtered.length !== params.length) {
|
|
params = filtered;
|
|
changed = true;
|
|
}
|
|
return changed ? { ...t, params } : t;
|
|
}),
|
|
);
|
|
}, [active, activeTab.xslt]);
|
|
|
|
const handleAddWorkspace = useCallback(() => {
|
|
setTabs((current) => {
|
|
if (current.length >= MAX_WORKSPACES) {
|
|
window.alert(`You can only open up to ${MAX_WORKSPACES} workspaces.`);
|
|
return current;
|
|
}
|
|
const nextTab = defaultTab();
|
|
setWorkspaceStatus((prev) => ({
|
|
...prev,
|
|
[nextTab.id]: defaultWorkspaceStatus(),
|
|
}));
|
|
setActive(nextTab.id);
|
|
return [...current, nextTab];
|
|
});
|
|
}, [setActive, setWorkspaceStatus]);
|
|
|
|
const handleRemoveWorkspace = useCallback(
|
|
(id) => {
|
|
setTabs((current) => {
|
|
if (current.length <= 1) {
|
|
return current;
|
|
}
|
|
const filtered = current.filter((t) => t.id !== id);
|
|
if (!filtered.length) {
|
|
return current;
|
|
}
|
|
const nextActiveId = id === active ? filtered[0].id : active;
|
|
setActive(nextActiveId);
|
|
setWorkspaceStatus((prev) => {
|
|
const next = { ...prev };
|
|
delete next[id];
|
|
if (nextActiveId && !next[nextActiveId]) {
|
|
next[nextActiveId] = defaultWorkspaceStatus();
|
|
}
|
|
return next;
|
|
});
|
|
return filtered;
|
|
});
|
|
},
|
|
[active, setActive, setWorkspaceStatus],
|
|
);
|
|
|
|
const handleClearWorkspace = useCallback(
|
|
(tabData) => {
|
|
const targetId = typeof tabData === "object" ? tabData?.id : tabData;
|
|
if (!targetId) return;
|
|
if (
|
|
!window.confirm(
|
|
"¿Limpiar este workspace? Se perderán los cambios no guardados.",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
setTabs((current) =>
|
|
current.map((t) => {
|
|
if (t.id !== targetId) return t;
|
|
const cleared = {
|
|
...defaultTab({ id: t.id, name: t.name }),
|
|
params: [],
|
|
};
|
|
return cleared;
|
|
}),
|
|
);
|
|
setWorkspaceStatus((prev) => ({
|
|
...prev,
|
|
[targetId]: defaultWorkspaceStatus(),
|
|
}));
|
|
},
|
|
[setTabs, setWorkspaceStatus],
|
|
);
|
|
|
|
const runTransform = debounce(async (xsltText, ver, p, tabId) => {
|
|
const paramObj = {};
|
|
p.forEach((pr) => {
|
|
if (pr.name) paramObj[pr.name] = pr.value;
|
|
});
|
|
try {
|
|
const res = await fetch(`${backendBase}/transform`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
xslt: xsltText,
|
|
version: ver,
|
|
parameters: paramObj,
|
|
trace: traceEnabled,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
let txt = "";
|
|
try {
|
|
// Prefer JSON to decode escaped newlines (\n)
|
|
const j = await res.json();
|
|
if (j && typeof j.error === "string") {
|
|
txt = j.error;
|
|
} else {
|
|
txt = JSON.stringify(j);
|
|
}
|
|
} catch {
|
|
// Fallback to raw text
|
|
txt = await res.text();
|
|
}
|
|
const lines = parseErrorLines(txt || res.statusText || "");
|
|
updateWorkspaceStatus(tabId, {
|
|
error: txt || res.statusText,
|
|
errorLines: lines,
|
|
duration: null,
|
|
result: "",
|
|
traceEntries: [],
|
|
traceText: "",
|
|
showRawTrace: false,
|
|
resultView: "source",
|
|
});
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
const defaultView = looksLikeHtml(data.result) ? "render" : "source";
|
|
updateWorkspaceStatus(tabId, {
|
|
result: data.result,
|
|
duration: data.duration_ms,
|
|
error: "",
|
|
errorLines: [],
|
|
showRawTrace: false,
|
|
resultView: defaultView,
|
|
});
|
|
const newEntries = traceEnabled ? (data.trace || []) : [];
|
|
updateWorkspaceStatus(tabId, (prev) => ({
|
|
...prev,
|
|
traceEntries: newEntries,
|
|
traceText: traceEnabled ? (data.trace_text || "") : "",
|
|
}));
|
|
requestAnimationFrame(() => {
|
|
clampTraceNameWidth();
|
|
});
|
|
} catch (e) {
|
|
const txt = String(e);
|
|
updateWorkspaceStatus(tabId, {
|
|
error: txt,
|
|
errorLines: parseErrorLines(txt),
|
|
result: "",
|
|
duration: null,
|
|
traceEntries: [],
|
|
traceText: "",
|
|
showRawTrace: false,
|
|
resultView: "source",
|
|
});
|
|
}
|
|
}, 500);
|
|
|
|
useEffect(() => {
|
|
if (!activeTab || !autoRunReady) return;
|
|
runTransform(
|
|
injectParamBlock(activeTab.xslt, activeTab.params),
|
|
activeTab.version,
|
|
activeTab.params,
|
|
activeTab.id,
|
|
);
|
|
}, [activeTab, traceEnabled, autoRunReady]);
|
|
|
|
useEffect(() => {
|
|
syncParams();
|
|
}, [active, syncParams]);
|
|
|
|
useEffect(() => {
|
|
if (!editorFocused) syncParams();
|
|
}, [editorFocused, syncParams]);
|
|
|
|
|
|
const updateParam = (index, field, value) => {
|
|
setTabs((tabs) =>
|
|
tabs.map((t) => {
|
|
if (t.id !== active) return t;
|
|
const params = [...t.params];
|
|
if (field === "open" && value) {
|
|
params.forEach((p, i) => {
|
|
if (i !== index) p.open = false;
|
|
});
|
|
}
|
|
params[index] = { ...params[index], [field]: value };
|
|
return { ...t, params };
|
|
}),
|
|
);
|
|
};
|
|
|
|
const addParam = () => {
|
|
setTabs((tabs) =>
|
|
tabs.map((t) =>
|
|
t.id === active ? { ...t, params: [...t.params, { name: "", value: "", open: false }] } : t,
|
|
),
|
|
);
|
|
};
|
|
|
|
const removeParam = (index) => {
|
|
setTabs((tabs) =>
|
|
tabs.map((t) =>
|
|
t.id === active ? { ...t, params: t.params.filter((_, i) => i !== index) } : t,
|
|
),
|
|
);
|
|
};
|
|
|
|
const loadFile = (e, setter, prep = (t) => t) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => setter(prep(reader.result));
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
const handleDrop = (e, setter) => {
|
|
e.preventDefault();
|
|
const file = e.dataTransfer.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => setter(reader.result);
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
const handleDropNewParam = (e) => {
|
|
e.preventDefault();
|
|
const file = e.dataTransfer.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const name = file.name.replace(/\.[^.]+$/, "");
|
|
setTabs((tabs) =>
|
|
tabs.map((t) =>
|
|
t.id === active
|
|
? {
|
|
...t,
|
|
params: [
|
|
...t.params,
|
|
{ name, value: reader.result, open: false },
|
|
],
|
|
}
|
|
: t,
|
|
),
|
|
);
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
const handleResultResizeStart = (event) => {
|
|
event.preventDefault();
|
|
resultResizeState.current = {
|
|
startY: event.clientY,
|
|
startHeight: resolvedResultHeight,
|
|
};
|
|
setIsResizingResult(true);
|
|
};
|
|
|
|
const handleParamResizeStart = (event) => {
|
|
if (paramsCollapsed) return;
|
|
event.preventDefault();
|
|
paramResizeState.current = {
|
|
startX: event.clientX,
|
|
startWidth: paramWidth || DEFAULT_PARAM_WIDTH,
|
|
};
|
|
setIsResizingParams(true);
|
|
};
|
|
|
|
const handleResetResultHeight = () => {
|
|
setResultHeight(null);
|
|
};
|
|
|
|
const handleRenameWorkspace = useCallback((id, name) => {
|
|
setTabs((current) =>
|
|
current.map((tab) =>
|
|
tab.id === id
|
|
? { ...tab, name: typeof name === "string" ? name.trim() : "" }
|
|
: tab,
|
|
),
|
|
);
|
|
}, []);
|
|
|
|
const download = useCallback((data, filename, mimeType = "text/xml") => {
|
|
const blob = new Blob([data], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}, []);
|
|
|
|
const handleExportWorkspace = useCallback(
|
|
(targetTab) => {
|
|
const tab = targetTab || activeTab;
|
|
if (!tab) return;
|
|
const statusSnapshot =
|
|
workspaceStatus[tab.id] || defaultWorkspaceStatus();
|
|
const payload = {
|
|
schemaVersion: WORKSPACE_EXPORT_VERSION,
|
|
exportedAt: new Date().toISOString(),
|
|
workspace: {
|
|
params: tab.params,
|
|
xslt: tab.xslt,
|
|
version: tab.version,
|
|
},
|
|
status: statusSnapshot,
|
|
};
|
|
download(
|
|
JSON.stringify(payload, null, 2),
|
|
`workspace-${tab.id}.json`,
|
|
"application/json",
|
|
);
|
|
},
|
|
[activeTab, download, workspaceStatus],
|
|
);
|
|
|
|
const handleWorkspaceImport = useCallback(
|
|
(event) => {
|
|
const input = event.target;
|
|
const file = input?.files?.[0];
|
|
if (!file) {
|
|
if (input) input.value = "";
|
|
return;
|
|
}
|
|
if (tabsRef.current.length >= MAX_WORKSPACES) {
|
|
window.alert(
|
|
`You can only keep up to ${MAX_WORKSPACES} workspaces simultaneously.`,
|
|
);
|
|
if (input) input.value = "";
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
try {
|
|
const parsed = JSON.parse(reader.result || "{}");
|
|
const normalized = normalizeWorkspaceImport(parsed);
|
|
setTabs((current) => {
|
|
if (current.length >= MAX_WORKSPACES) {
|
|
window.alert(
|
|
`You can only keep up to ${MAX_WORKSPACES} workspaces simultaneously.`,
|
|
);
|
|
return current;
|
|
}
|
|
const nextTab = defaultTab({
|
|
params: normalized.workspace.params,
|
|
xslt: normalized.workspace.xslt,
|
|
version: normalized.workspace.version,
|
|
});
|
|
setWorkspaceStatus((prev) => ({
|
|
...prev,
|
|
[nextTab.id]: normalized.status,
|
|
}));
|
|
setActive(nextTab.id);
|
|
return [...current, nextTab];
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
window.alert(
|
|
typeof err?.message === "string"
|
|
? err.message
|
|
: "Failed to import workspace file.",
|
|
);
|
|
} finally {
|
|
if (input) input.value = "";
|
|
}
|
|
};
|
|
reader.onerror = () => {
|
|
window.alert("Unable to read the workspace file.");
|
|
if (input) input.value = "";
|
|
};
|
|
reader.readAsText(file);
|
|
},
|
|
[setTabs, setActive, setWorkspaceStatus],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!adsenseClient || !adsenseSlot) return;
|
|
const cancelIdle = runWhenIdle(() => {
|
|
if (!window.adsbygoogle) return;
|
|
try {
|
|
window.adsbygoogle.push({});
|
|
} catch {}
|
|
}, 2000);
|
|
return () => cancelIdle?.();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!ethicalAdsEnabled) {
|
|
setEthicalAdsReady(false);
|
|
return;
|
|
}
|
|
if (window.ethicalads) {
|
|
setEthicalAdsReady(true);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
let script = null;
|
|
let fallback = null;
|
|
let onLoad = null;
|
|
let onError = null;
|
|
const loadEthicalAds = () => {
|
|
if (cancelled) return;
|
|
const existing = document.querySelector("script[data-ethicalads]");
|
|
script = existing || document.createElement("script");
|
|
if (!existing) {
|
|
script.src = "https://media.ethicalads.io/media/client/ethicalads.min.js";
|
|
script.async = true;
|
|
script.dataset.ethicalads = "true";
|
|
document.body.appendChild(script);
|
|
}
|
|
onLoad = () => setEthicalAdsReady(Boolean(window.ethicalads));
|
|
onError = () => setEthicalAdsReady(false);
|
|
script.addEventListener("load", onLoad);
|
|
script.addEventListener("error", onError);
|
|
fallback = window.setTimeout(() => {
|
|
if (!window.ethicalads) setEthicalAdsReady(false);
|
|
}, 3500);
|
|
};
|
|
const cancelIdle = runWhenIdle(loadEthicalAds, 2000);
|
|
return () => {
|
|
cancelled = true;
|
|
cancelIdle?.();
|
|
if (script && onLoad) script.removeEventListener("load", onLoad);
|
|
if (script && onError) script.removeEventListener("error", onError);
|
|
if (fallback) window.clearTimeout(fallback);
|
|
};
|
|
}, [ethicalAdsEnabled]);
|
|
|
|
useEffect(() => {
|
|
if (!ethicalAdsEnabled || !ethicalAdsReady || !ethicalSlotRef.current) return;
|
|
try {
|
|
ethicalSlotRef.current.innerHTML = "";
|
|
window.ethicalads?.load(ethicalSlotRef.current);
|
|
} catch {}
|
|
}, [ethicalAdsEnabled, ethicalAdsReady, ethicalAdVariant]);
|
|
|
|
return (
|
|
<div className="app-container">
|
|
<h1 className="sr-only">XSLT Playground - Online XSLT Editor and Tester</h1>
|
|
{ethicalAdsEnabled && (
|
|
<div
|
|
ref={ethicalSlotRef}
|
|
className="ethical-ad-placeholder"
|
|
data-ea-publisher={ethicalAdsPublisher}
|
|
data-ea-type={ethicalAdType}
|
|
data-ea-style={ethicalAdStyle}
|
|
style={{ height: ethicalAdHeight }}
|
|
aria-label="Advertisement"
|
|
/>
|
|
)}
|
|
<div className="tabs">
|
|
<TabsNav
|
|
tabs={tabs}
|
|
activeId={active}
|
|
onSelect={setActive}
|
|
onClose={handleRemoveWorkspace}
|
|
onExport={handleExportWorkspace}
|
|
onClear={handleClearWorkspace}
|
|
onRename={handleRenameWorkspace}
|
|
/>
|
|
<div className="tabs-right">
|
|
<button
|
|
type="button"
|
|
className="icon-button tab-add"
|
|
onClick={handleAddWorkspace}
|
|
disabled={tabs.length >= MAX_WORKSPACES}
|
|
aria-label="Add workspace"
|
|
title={
|
|
tabs.length >= MAX_WORKSPACES
|
|
? "Maximum number of workspaces reached"
|
|
: "Add workspace"
|
|
}
|
|
>
|
|
<Icon name="plus" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button tab-import"
|
|
onClick={() => workspaceImportRef.current?.click()}
|
|
title="Import workspace"
|
|
aria-label="Import workspace"
|
|
>
|
|
<Icon name="import" />
|
|
</button>
|
|
<input
|
|
ref={workspaceImportRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
className="file-input"
|
|
onChange={handleWorkspaceImport}
|
|
aria-label="Import workspace file"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="main">
|
|
{paramsCollapsed ? (
|
|
<div className="params-collapsed">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
title="Show data pipeline"
|
|
aria-label="Show data pipeline"
|
|
onClick={() => setParamsCollapsed(false)}
|
|
>
|
|
<Icon name="chevron-right" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
className="params"
|
|
style={{
|
|
width: `${paramWidth}px`,
|
|
flexBasis: `${paramWidth}px`,
|
|
}}
|
|
>
|
|
<DataPipelineHeader
|
|
collapsed={paramsCollapsed}
|
|
onToggleCollapsed={() => setParamsCollapsed((v) => !v)}
|
|
onAddParam={addParam}
|
|
/>
|
|
<div
|
|
className="params-body"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={handleDropNewParam}
|
|
>
|
|
{activeTab.params.map((p, i) => (
|
|
<div key={i} className={`param-card${p.open ? " open" : ""}`}>
|
|
<div className="param-header-row">
|
|
<div className="param-name-wrap">
|
|
<button
|
|
type="button"
|
|
className={`icon-button param-toggle${p.open ? " open" : ""}`}
|
|
aria-label={p.open ? "Collapse parameter details" : "Expand parameter details"}
|
|
onClick={() => updateParam(i, "open", !p.open)}
|
|
>
|
|
<Icon name={p.open ? "chevron-down" : "chevron-right"} />
|
|
</button>
|
|
<input
|
|
className="param-name-input"
|
|
placeholder="Parameter name"
|
|
value={p.name}
|
|
onChange={(e) => updateParam(i, "name", e.target.value)}
|
|
aria-label="Parameter name"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="icon-button param-remove"
|
|
aria-label="Remove parameter"
|
|
onClick={() => removeParam(i)}
|
|
>
|
|
<Icon name="close" />
|
|
</button>
|
|
</div>
|
|
{p.open && (
|
|
<div
|
|
className="param-content"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.stopPropagation();
|
|
handleDrop(e, (t) => updateParam(i, "value", t));
|
|
}}
|
|
>
|
|
<div className="param-editor">
|
|
<Editor
|
|
height="150px"
|
|
language="xml"
|
|
theme={editorTheme}
|
|
value={p.value}
|
|
onChange={(v) => updateParam(i, "value", v || "")}
|
|
options={{
|
|
minimap: { enabled: false },
|
|
automaticLayout: true,
|
|
lineNumbers: "off",
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="param-footer">
|
|
<label
|
|
className="icon-button file-label param-upload"
|
|
aria-label="Upload parameter value"
|
|
title="Upload parameter value"
|
|
>
|
|
<Icon name="upload" />
|
|
<input
|
|
type="file"
|
|
accept=".xml"
|
|
className="file-input"
|
|
onChange={(e) => loadFile(e, (t) => updateParam(i, "value", t))}
|
|
aria-label="Upload parameter value file"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="icon-button param-download"
|
|
aria-label="Download parameter value"
|
|
onClick={() => download(p.value, `${p.name || "param"}.xml`)}
|
|
>
|
|
<Icon name="download" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="drop-hint">Drop your input XML files here..</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`pane-divider${isResizingParams ? " dragging" : ""}`}
|
|
onMouseDown={handleParamResizeStart}
|
|
role="separator"
|
|
aria-label="Resize data pipeline width"
|
|
aria-orientation="vertical"
|
|
>
|
|
<span />
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="editor">
|
|
<div className="toggle">
|
|
<select
|
|
className="version-select"
|
|
value={activeTab.version}
|
|
onChange={(e) =>
|
|
setTabs((tabs) =>
|
|
tabs.map((t) =>
|
|
t.id === active
|
|
? {
|
|
...t,
|
|
version: e.target.value,
|
|
xslt: setStylesheetVersion(t.xslt, e.target.value),
|
|
}
|
|
: t,
|
|
),
|
|
)
|
|
}
|
|
aria-label="XSLT version"
|
|
>
|
|
<option value="1.0">XSLT 1.0</option>
|
|
<option value="2.0">XSLT 2.0</option>
|
|
</select>
|
|
<label className="trace-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={traceEnabled}
|
|
onChange={(e) => setTraceEnabled(e.target.checked)}
|
|
/>
|
|
<span className="trace-toggle-box" aria-hidden="true" />
|
|
<span className="trace-toggle-label">Enable Internal Variables</span>
|
|
</label>
|
|
<div className="right-actions">
|
|
<label
|
|
className="icon-button file-label"
|
|
aria-label="Upload stylesheet"
|
|
title="Upload stylesheet"
|
|
>
|
|
<Icon name="upload" />
|
|
<input
|
|
type="file"
|
|
accept=".xsl,.xslt"
|
|
className="file-input"
|
|
aria-label="Upload stylesheet file"
|
|
onChange={(e) =>
|
|
loadFile(e, (t) =>
|
|
setTabs((tabs) =>
|
|
tabs.map((tab) =>
|
|
tab.id === active
|
|
? { ...tab, xslt: stripParamBlock(t), params: addParams(t,tab) }
|
|
: tab,
|
|
),
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<button
|
|
className="icon-button"
|
|
aria-label="Download stylesheet"
|
|
title="Download stylesheet"
|
|
onClick={() =>
|
|
download(
|
|
injectParamBlock(activeTab.xslt, activeTab.params),
|
|
"transform.xsl",
|
|
)
|
|
}
|
|
>
|
|
<Icon name="download" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="editor-split">
|
|
<div className="xslt-editor-wrap">
|
|
<Editor
|
|
height="100%"
|
|
language="xml"
|
|
theme={editorTheme}
|
|
wrapperProps={{
|
|
onDragOver: (e) => e.preventDefault(),
|
|
onDrop: (e) =>
|
|
handleDrop(e, (t) =>
|
|
setTabs((tabs) =>
|
|
tabs.map((tab) =>
|
|
tab.id === active ? { ...tab, xslt: stripParamBlock(t), params: addParams(t,tab) } : tab,
|
|
),
|
|
),
|
|
),
|
|
}}
|
|
value={editorFocused ? activeTab.xslt : injectParamBlock(activeTab.xslt, activeTab.params)}
|
|
onChange={(v) =>
|
|
setTabs((tabs) =>
|
|
tabs.map((tab) =>
|
|
tab.id === active ? { ...tab, xslt: stripParamBlock(v || ""), params: addParams(v,tab) } : tab,
|
|
),
|
|
)
|
|
}
|
|
onFocus={() => setEditorFocused(true)}
|
|
onBlur={() => {
|
|
setEditorFocused(false);
|
|
syncParams();
|
|
}}
|
|
options={{ minimap: { enabled: false }, automaticLayout: true }}
|
|
/>
|
|
</div>
|
|
{traceEnabled && (
|
|
<div className="trace-panel" style={{ width: traceCollapsed ? '2rem' : '30%' }}>
|
|
<div className="trace-header" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<button
|
|
className="icon-button"
|
|
title={traceCollapsed ? 'Show trace' : 'Hide trace'}
|
|
onClick={() => setTraceCollapsed(v => !v)}
|
|
aria-label={traceCollapsed ? 'Show trace panel' : 'Hide trace panel'}
|
|
>
|
|
<Icon name={traceCollapsed ? "chevron-right" : "chevron-down"} />
|
|
</button>
|
|
{!traceCollapsed && (
|
|
<>
|
|
<span style={{ fontWeight: 'bold' }}>
|
|
Trace Variables {traceEntries.length ? `(${traceEntries.length})` : ''}
|
|
</span>
|
|
<div className="trace-header-actions">
|
|
<button
|
|
className="icon-button"
|
|
title="Copy all trace variables"
|
|
onClick={handleCopyAllTrace}
|
|
type="button"
|
|
disabled={!traceEntries.length && !traceText}
|
|
aria-label="Copy all trace variables"
|
|
>
|
|
<Icon name="copy" />
|
|
</button>
|
|
{traceText && (
|
|
<button
|
|
className="icon-button"
|
|
title={showRawTrace ? "Hide raw trace output" : "Show raw trace output"}
|
|
onClick={() => {
|
|
if (activeTab) {
|
|
updateWorkspaceStatus(activeTab.id, (prev) => ({
|
|
...prev,
|
|
showRawTrace: !prev.showRawTrace,
|
|
}));
|
|
}
|
|
}}
|
|
type="button"
|
|
aria-label={showRawTrace ? "Hide raw trace output" : "Show raw trace output"}
|
|
>
|
|
<Icon name={showRawTrace ? "terminal-off" : "terminal"} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{!traceCollapsed && (
|
|
<div className="trace-content">
|
|
{traceEntries.length > 0 && (
|
|
<div
|
|
className="trace-table-wrap"
|
|
ref={traceTableWrapRef}
|
|
onScroll={(e) => setTraceScrollLeft(e.currentTarget.scrollLeft)}
|
|
>
|
|
<table className="trace-table">
|
|
<colgroup>
|
|
<col style={{ width: `${traceNameWidth}px` }} />
|
|
<col />
|
|
</colgroup>
|
|
<tbody>
|
|
{traceEntries.map((t, i) => {
|
|
const rawName = (t?.name ?? "").toString();
|
|
const rawValue = (t?.value ?? "").toString();
|
|
const namePreview = truncateText(rawName, TRACE_NAME_LIMIT);
|
|
const valuePreview = truncateText(rawValue, TRACE_VALUE_LIMIT);
|
|
return (
|
|
<tr key={`${rawName}-${i}`}>
|
|
<td className="trace-name">
|
|
<div
|
|
className="trace-cell"
|
|
onMouseEnter={(e) => showTraceTooltip(e, rawName)}
|
|
onMouseLeave={hideTraceTooltip}
|
|
>
|
|
<pre
|
|
className="trace-preview trace-name-preview"
|
|
ref={registerTraceNameRef(i)}
|
|
>
|
|
{namePreview || EMPTY_SYMBOL}
|
|
</pre>
|
|
</div>
|
|
</td>
|
|
<td className="trace-value">
|
|
<div
|
|
className="trace-cell"
|
|
onMouseEnter={(e) => showTraceTooltip(e, rawValue)}
|
|
onMouseLeave={hideTraceTooltip}
|
|
>
|
|
<pre className="trace-preview trace-value-preview">{valuePreview || EMPTY_SYMBOL}</pre>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
<div
|
|
className="trace-divider"
|
|
style={{ left: `${traceNameWidth - 3 - traceScrollLeft}px` }}
|
|
onMouseDown={handleTraceDividerMouseDown}
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
/>
|
|
</div>
|
|
)}
|
|
{showRawTrace && traceText && (
|
|
<pre className="trace-raw-block">{traceText}</pre>
|
|
)}
|
|
{!traceEntries.length && !traceText && (
|
|
<div className="trace-empty">Trace output is empty.</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`result-resizer${isResizingResult ? " dragging" : ""}`}
|
|
onMouseDown={handleResultResizeStart}
|
|
role="separator"
|
|
aria-label="Resize result pane"
|
|
aria-orientation="horizontal"
|
|
>
|
|
<span />
|
|
</div>
|
|
<div
|
|
className="result"
|
|
style={{
|
|
height: `${resolvedResultHeight}px`,
|
|
minHeight: `${MIN_RESULT_HEIGHT}px`,
|
|
}}
|
|
>
|
|
{error && !errorCollapsed && (
|
|
<div className="error-box" role="alert" aria-live="assertive">
|
|
<div className="error-box-header">
|
|
<span>Errors</span>
|
|
<div className="error-box-actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={copyErrors}
|
|
disabled={!canCopyErrors}
|
|
title="Copy all errors"
|
|
aria-label="Copy errors"
|
|
>
|
|
<Icon name="copy" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={() => setErrorCollapsed(true)}
|
|
title="Hide errors"
|
|
aria-label="Hide errors"
|
|
>
|
|
<Icon name="chevron-up" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{limitedErrorLines.length > 0 ? (
|
|
<table className="error-table">
|
|
<tbody>
|
|
{limitedErrorLines.map((l, i) => (
|
|
<tr key={i} className="error-row">
|
|
<td className="error-icon" aria-hidden>
|
|
<Icon name="alert" />
|
|
</td>
|
|
<td className="error-text" title={l}>
|
|
{l}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
<div className="error-line">
|
|
<span className="error-icon" aria-hidden>
|
|
<Icon name="alert" />
|
|
</span>
|
|
<span className="error-text" title={error || ""}>
|
|
{error}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{hasHiddenErrors && (
|
|
<div className="error-more">
|
|
+{(errorLines || []).length - MAX_ERROR_LINES} more…
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{error && errorCollapsed && (
|
|
<button
|
|
type="button"
|
|
className="icon-button error-expand-button"
|
|
onClick={() => setErrorCollapsed(false)}
|
|
title="Show errors"
|
|
aria-label="Show errors"
|
|
>
|
|
<Icon name="alert" />
|
|
</button>
|
|
)}
|
|
{showResultPane && (
|
|
<>
|
|
{duration !== null && (
|
|
<div className="success-box" role="status" aria-live="polite">
|
|
Success in {duration} ms
|
|
</div>
|
|
)}
|
|
<div className="result-actions">
|
|
{canRenderHtml && (
|
|
<button
|
|
type="button"
|
|
className={`icon-button result-view-toggle${effectiveResultView === "render" ? " active" : ""}`}
|
|
onClick={() => {
|
|
if (!activeTab) return;
|
|
const next = effectiveResultView === "render" ? "source" : "render";
|
|
updateWorkspaceStatus(activeTab.id, (prev) => ({
|
|
...prev,
|
|
resultView: next,
|
|
}));
|
|
}}
|
|
title={
|
|
effectiveResultView === "render"
|
|
? "Show source instead of rendered HTML"
|
|
: "Render HTML output"
|
|
}
|
|
aria-label={
|
|
effectiveResultView === "render"
|
|
? "Show source instead of rendered HTML"
|
|
: "Render HTML output"
|
|
}
|
|
>
|
|
<Icon name={effectiveResultView === "render" ? "code" : "globe"} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className="icon-button result-format-button"
|
|
disabled={effectiveResultView !== "source"}
|
|
onClick={async () => {
|
|
if (effectiveResultView !== "source") return;
|
|
try {
|
|
const { default: formatXML } = await import("xml-formatter");
|
|
const formatted = formatXML(result);
|
|
if (activeTab) {
|
|
updateWorkspaceStatus(activeTab.id, (prev) => ({
|
|
...prev,
|
|
result: formatted,
|
|
}));
|
|
}
|
|
} catch {}
|
|
}}
|
|
title="Format result as pretty XML"
|
|
aria-label="Format result as pretty XML"
|
|
>
|
|
<Icon name="sparkles" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`icon-button result-reset-button${isCustomResultHeight ? " active" : ""}`}
|
|
onClick={handleResetResultHeight}
|
|
title="Reset result pane height"
|
|
aria-label="Reset result pane height"
|
|
>
|
|
<Icon name="refresh" />
|
|
</button>
|
|
</div>
|
|
<div className="result-editor-wrap">
|
|
{effectiveResultView === "render" && canRenderHtml ? (
|
|
<div className="result-render">
|
|
<iframe
|
|
title="Rendered HTML output"
|
|
srcDoc={result || "<!-- empty -->"}
|
|
sandbox=""
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Editor
|
|
height="100%"
|
|
language="xml"
|
|
theme={editorTheme}
|
|
value={result}
|
|
onMount={(editor) => (resultEditorRef.current = editor)}
|
|
options={{
|
|
readOnly: true,
|
|
minimap: { enabled: false },
|
|
automaticLayout: true,
|
|
wordWrap: "bounded",
|
|
wordWrapBreakAfterCharacters: ' \t})]?|>'
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="footer">
|
|
<div className="footer-left">
|
|
<img src={logo} alt="XSLT Playground logo" className="logo" />
|
|
<strong>xsltplayground.com</strong>
|
|
<span className="footer-tagline">Free XSLT Editor & Tester</span>
|
|
<a
|
|
className="news-link"
|
|
href={newsUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
Blog & Tutorials
|
|
</a>
|
|
{resolvedVersion && (
|
|
<a
|
|
className="version-pill"
|
|
href={changelogUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title="View CHANGELOG"
|
|
>
|
|
v{resolvedVersion}
|
|
</a>
|
|
)}
|
|
</div>
|
|
<div className="footer-right">
|
|
<span>© 2025 Alexandre Vazquez. All rights reserved.</span>
|
|
<a
|
|
href="https://alexandre-vazquez.com"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
alexandre-vazquez.com
|
|
</a>
|
|
<button
|
|
type="button"
|
|
className={`icon-button footer-theme-toggle${isDarkTheme ? " active" : ""}`}
|
|
onClick={() => setTheme(isDarkTheme ? THEME_LIGHT : THEME_DARK)}
|
|
title="Toggle dark mode"
|
|
aria-label="Toggle dark mode"
|
|
>
|
|
<Icon name="moon" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{traceHover && (
|
|
<div
|
|
className="trace-hover-tooltip"
|
|
style={{ top: traceHover.y, left: traceHover.x, maxWidth: traceHover.width }}
|
|
onMouseEnter={cancelTraceTooltipHide}
|
|
onMouseLeave={() => {
|
|
cancelTraceTooltipHide();
|
|
setTraceHover(null);
|
|
}}
|
|
>
|
|
<div className="trace-hover-actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={copyTraceHover}
|
|
title="Copy value"
|
|
aria-label="Copy value"
|
|
>
|
|
<Icon name="copy" />
|
|
</button>
|
|
</div>
|
|
<pre>{traceHover.text}</pre>
|
|
</div>
|
|
)}
|
|
{widgetsReady && (
|
|
<Suspense fallback={null}>
|
|
<BuyMeACoffee />
|
|
<FeedbackWidget />
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* Optional Firebase Auth example
|
|
import { initializeApp } from 'firebase/app';
|
|
import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
|
|
// const firebaseConfig = { ... };
|
|
// const app = initializeApp(firebaseConfig);
|
|
// const auth = getAuth(app);
|
|
// const provider = new GoogleAuthProvider();
|
|
// function login() { signInWithPopup(auth, provider); }
|
|
*/
|