UNPKG

ebnf-railroad-visualizer

Version:
884 lines 35.6 kB
/* * This work © 2024 by Alexander Voglsperger is licensed under CC BY 4.0. * To view a copy of this license, see the provided LICENSE file or visit https://creativecommons.org/licenses/by/4.0/ */ /* * ooOOOO * oo _____ * _I__n_n__||_|| ________ * >(_________|_7_|-|______| * /o ()() ()() o oo oo * * This file contains the main logic for the EBNF Railroad Visualizer. */ import { Diagram } from "./Diagram.js"; import { Grammar } from "./Grammar.js"; import { compressAndEncode, decodeAndDecompress } from "./Compressor.js"; const GENERATION_TIMEOUT = 100; const COMPRESSION_THRESHOLD = 100; const PNG_EXPORT_SCALE_FACTOR = 4; // parameter names for URL data const COMPRESSED_GRAMMAR_PARAM_LZ = "grammarlz"; const COMPRESSED_GRAMMAR_PARAM_GZIP = "grammargzip"; const GRAMMAR_PARAM = "grammar"; const COMPRESSED_EXPAND_PARAM_LZ = "expandlz"; const COMPRESSED_EXPAND_PARAM_GZIP = "expandlz"; const EXPAND_PARAM = "expand"; const START_SYMBOL_PARAM = "start"; let d3; let errorMessageContainer; let diagramContainer; let ebnfGrammarArea; let startSymbolDropDown; let startSymbolSelect; let zoom; let focusElementPath = ""; let timeoutId; let chooChoo; /** * Installs provided functions into `window`, initializes global variables and adds listeners. * * Provided functions: * - `generateDiagram` - Generate the diagram from the entered grammar. * - `handleGenerateDiagram` - Handle the generation of a diagram from the entered grammar. * - `handleStartSymbolSelection` - Handle the selection of a new start symbol. * - `onCollapseAll` - Handle the collapse of all non-terminal-symbols. * - `onExpandAll` - Handle the expansion of all non-terminal-symbols. * - `exportSvg ` - Export the diagram as an SVG file. * - `exportPng` - Export the diagram as a PNG file. * * Provided global variables: * - `chooChoo` - Holds all necessary objects for this package. * * Expected DOM elements: * - `error_message` - Container for error messages. * - `visualized-ebnf` - Container for the diagram. * - `ebnf_grammar` - Textarea for the EBNF grammar. * - `.start-symbol-drop-down` - Container for the start symbol dropdown. * - `start-symbol` - Select for the start symbol. * @param {Window} window The window object to install the functions into. * @param {typeof d3} d3Param The d3 to use. Must be imported in the main script and passed here. */ export function install(window, d3Param) { let elem = document.getElementById("error_message"); if (elem == null) throw new Error("Failed to find 'error_message' container"); else errorMessageContainer = elem; elem = document.getElementById("visualized-ebnf"); if (elem == null) throw new Error("Failed to find 'visualized-ebnf' container"); else diagramContainer = elem; elem = document.querySelector("textarea[name=ebnf_grammar]"); if (elem == null) throw new Error("Failed to find 'ebnf_grammar' textarea"); else ebnfGrammarArea = elem; elem = document.querySelector("#start-symbol"); if (elem == null) throw new Error("Failed to find 'start-symbol' select"); else startSymbolSelect = elem; startSymbolDropDown = document.querySelector(".start-symbol-drop-down"); if (d3Param == null) { console.warn("d3 not provided. Will not be able to zoom."); } d3 = d3Param; // Initialize provided functions window.generateDiagram = generateDiagram; window.handleGenerateDiagram = handleGenerateDiagram; window.handleStartSymbolSelection = handleStartSymbolSelection; window.onCollapseAll = onCollapseAll; window.onExpandAll = onExpandAll; window.exportSvg = () => exportSvg(); window.exportPng = () => exportPng(); // Add listeners window.addEventListener("resize", window.updateSvgViewBoxSize); // Initialize global variables and attach a container to the window for external access window.chooChoo = chooChoo = { toExtend: new Set(), currentStartSymbolName: "" }; } /** * Focus the element with the path stored in `focusElementPath`. */ function focusElementSvg() { if (d3 == null) { return; } if (focusElementPath.trim().length === 0) { return; } const svg = d3.select(".railroad-diagram"); // Select "title" element with the path as inner text let parent = Array.from(svg.node()?.querySelectorAll(`title`)) .filter((title) => title.innerHTML === focusElementPath)[0].parentNode; if (parent == null) throw new Error("Failed to find parent of focus element"); // If parent is a comment, get the parent of the parent if (parent.tagName === "g" && parent.classList.contains("comment")) { parent = parent.parentNode; } const bbox = parent.getBBox(); // Center of bbox const bboxCenterX = bbox.x + bbox.width / 2; const bboxCenterY = bbox.y + bbox.height / 2; // Calculate new transform based on bbox center and current svg size const viewBox = svg.node().viewBox.baseVal; const newTransform = d3.zoomIdentity.translate(viewBox.width / 2 - bboxCenterX, viewBox.height / 2 - bboxCenterY); console.debug(`Box center: (${bboxCenterX}, ${bboxCenterY})`); console.debug(`New transform: ${newTransform}`); // Apply transform svg.call(zoom.transform, newTransform); // Reset focus element focusElementPath = ""; } /** * Handle the generation of a diagram from the entered grammar. */ function handleGenerateDiagram() { generateDiagram(); updateUrl(); resetPathCleanupTimer(); } /** * Handle the collapse of all non-terminal-symbols. */ function onCollapseAll() { // Remove all non-terminals to extend chooChoo.toExtend.clear(); // Generate the diagram again generateDiagram(); updateUrl(); } /** * Handle the expansion of all non-terminal-symbols. */ function onExpandAll() { const ebnfGrammarValue = ebnfGrammarArea.value; if (ebnfGrammarValue.trim() === "") return; console.debug("Expanding all non-erminals"); // Replace current "chooChoo.toExtend" with all IDs that are expandable asyncString2Diagram(ebnfGrammarValue, chooChoo.currentStartSymbolName).then((diagram) => { chooChoo.toExtend = diagram.getAllNtsPaths(); generateDiagram(); updateUrl(); }).catch((e) => console.warn(e)); // Generate the diagram again } /** * Inject D3 zoom into the SVG. * Loosely based on https://stackoverflow.com/a/27993650/8527195 */ function injectD3() { if (d3 == null) { return; } const diagramSvg = document.getElementsByClassName("railroad-diagram")[0]; diagramSvg.removeAttribute("width"); diagramSvg.removeAttribute("height"); updateSvgViewBoxSize(); // Get the SVG element that D3-zoom should be applied to const svg = d3.select(".railroad-diagram"); // Get current childs of svg const children = Array.from(svg.node()?.children); // Add g node to the SVG const g = svg.append("g"); // Move all children to the g node children.forEach((child) => { g.node()?.appendChild(child); }); zoom = d3.zoom() .scaleExtent([0.1, 8]) .on("zoom", zoomed); svg.call(zoom); function zoomed({ transform }) { g.attr("transform", transform.toString()); } } /** * Generate the diagram from the entered grammar. */ function generateDiagram() { if (ebnfGrammarArea.value.trim() === "") { console.debug("Nothing was entered into the textarea."); diagramContainer.style.display = "none"; return; } const grammarVal = ebnfGrammarArea.value; const nonAscii = getNonAsciiChars(grammarVal); if (nonAscii.size > 0) { console.warn("The following non-ASCII characters were detected in the grammar: ", nonAscii); errorMessageContainer.innerHTML = `<p>Non-ASCII character(s) detected: ${Array.from(nonAscii).join(", ")}</p>`; errorMessageContainer.style.display = "block"; diagramContainer.style.display = "none"; return; } asyncString2Grammar(grammarVal).then((grammar) => { // Get all nts start symbols chooChoo.currentStartSymbolName = setStartSymbols(grammar.getStartSymbols(), chooChoo.currentStartSymbolName) || ""; return asyncGrammar2Diagram(grammar, chooChoo.currentStartSymbolName); }).then((diagram) => { diagramContainer.innerHTML = diagram.toSvg(chooChoo.toExtend); injectD3(); // Inject listeners for collapsing and expanding document.querySelectorAll(".non-terminal") .forEach(event => injectCollapseListener(event)); document.querySelectorAll("g.comment") .forEach(element => injectExpandListener(element)); // Set the focus to the element focusElementSvg(); // Hide the error message container errorMessageContainer.style.display = "none"; }).catch(e => { const PRODUCTION_NOT_FOUND_REGEX = /^Production '([^']*)' not found, but required for expansion$/; console.warn(`An error occurred while generating the diagram: ${e}`); if (!e.message.includes("but found") && !e.message.includes("Unknown character") && !PRODUCTION_NOT_FOUND_REGEX.test(e.message)) { console.warn(e.stack); } errorMessageContainer.innerHTML = `<p>${e}</p>`; errorMessageContainer.style.display = "block"; // Don't hide graph if it is a "production X not found" error if (!PRODUCTION_NOT_FOUND_REGEX.test(e.message)) { //diagramContainer.style.display = "none"; diagramContainer.innerHTML = ""; } }); } /** * Inject a listener for collapsing expanded non-terminal-symbols. * @param {HTMLElement} element The element to inject the listener into. */ function injectCollapseListener(element) { element.addEventListener("click", (event) => { if (event.currentTarget == null) { console.warn("Failed to find current target."); return; } if (event.ctrlKey) { // Use the pressed NTS as the start symbol const ntsName = event.currentTarget.querySelector("text")?.textContent ?? ""; if (startSymbolSelect == null) { console.warn("Failed to find start symbol select."); return; } startSymbolSelect.value = ntsName; handleStartSymbolSelection(); } else { // Get ID from iner child with tag "title" const title = event.currentTarget.querySelector("title"); const pathTitle = title?.textContent?.trim() ?? ""; // Expand the NTS const path = title2Path(pathTitle); if (path.length >= Diagram.MAX_EXPANSION_DEPTH) { console.warn(`Cannot expand NTS on path '${pathTitle}'. Depth limit reached!`); alert("Max. expansion depth reached!"); return; } // Add the ID to the set of non-terminals to extend chooChoo.toExtend.add(path); console.debug(`Expanding NTS on path '${pathTitle}'`); // Get "title" child of the clicked element and set the path as the focus element focusElementPath = event.currentTarget.querySelector("title")?.innerHTML ?? ""; // Generate the diagram again generateDiagram(); updateUrl(); } }); } /** * Inject a listener for expanding collapsed non-terminal-symbols. * @param {HTMLElement} element The element to inject the listener into. */ function injectExpandListener(element) { element.addEventListener("click", (event) => { // Get ID from inner child with tag "title" const title = event.currentTarget.querySelector("title"); const pathTitle = title?.textContent?.trim() ?? ""; // Add the ID to the set of non-terminals to extend console.debug(`Collapsing NTS on path '${pathTitle}'`); // Remove the pathTitle chooChoo.toExtend = new Set(Array.from(chooChoo.toExtend).filter((x) => x.join("-") !== pathTitle)); // Get "title" child of the clicked element and set the path as the focus element focusElementPath = event.currentTarget.querySelector("title")?.innerHTML ?? ""; // Generate the diagram again generateDiagram(); updateUrl(); }); } /** * Reset the path cleanup timer. */ function resetPathCleanupTimer() { clearTimeout(timeoutId); timeoutId = setTimeout(() => { try { const prevSize = chooChoo.toExtend.size; chooChoo.toExtend = filterInvalidPaths(ebnfGrammarArea.value, chooChoo.toExtend); if (prevSize !== chooChoo.toExtend.size) { console.debug(`Cleaned up ${prevSize - chooChoo.toExtend.size} paths`); generateDiagram(); updateUrl(); } else { console.debug("No paths to clean up"); } } catch (e) { console.warn(`An error occurred while cleaning up paths (this can likely be ignored): ${e}`); } }, 5000); } /** * Checks if a word starts with an uppercase letter. * * Because of course TS/JS doesn't have a built-in function for that. * @param word The word to check. * @returns `true` if the word starts with an uppercase letter, `false` otherwise. */ export function isUppercase(word) { return /^\p{Lu}/u.test(word); } /** * Asynchronously generate a diagram from a given grammar string. * @param {string} grammar - The grammar to generate a diagram from. * @param {string} startSymbolName - The name of the start symbol. If not provided the first production is used. * @returns {Promise<Diagram>} - The generated diagram. * @throws {Error} - If the diagram could not be generated or took longer than the timeout. */ async function asyncString2Diagram(grammar, startSymbolName) { console.debug("Generating diagram…"); return new Promise((resolve, reject) => { // Timeout to prevent blocking the UI or freezing the browser asyncString2Grammar(grammar).then((grammar) => { resolve(asyncGrammar2Diagram(grammar, startSymbolName)); }).catch(reject); }); } /** * Asynchronously generate a diagram from a given grammar. * @param {Grammar} grammar - The grammar to generate a diagram from. * @param {string} startSymbolName - The name of the start symbol. If not provided the first production is used. * @param {string} startSymbolName - The name of the start symbol. If not provided the first production is used. * @returns {Promise<Diagram>} - The generated diagram. */ async function asyncGrammar2Diagram(grammar, startSymbolName) { console.debug("Generating diagram…"); return new Promise((resolve, reject) => { // Timeout to prevent blocking the UI or freezing the browser const timeoutID = setTimeout(() => { console.debug("Generation Timeout"); reject(); }, GENERATION_TIMEOUT); try { // Generate the diagram const diagram = Diagram.fromGrammar(grammar, startSymbolName); console.debug("Diagram generated successfully."); clearTimeout(timeoutID); resolve(diagram); } catch (e) { clearTimeout(timeoutID); reject(e); } }); } /** * Asynchronously generate a grammar from a given grammar string * @param {string} grammar - The grammar string to generate from * @returns {Promise<Grammar>} - The generated grammar */ async function asyncString2Grammar(grammar) { console.debug("Scanning/Parsing grammar"); return new Promise((resolve, reject) => { // Timeout to prevent blocking the UI or freezing the browser const timeoutID = setTimeout(() => { console.debug("Generation Timeout"); reject(); }, GENERATION_TIMEOUT); try { // Generate the diagram const g = Grammar.fromString(grammar); console.debug("Grammar scanned/parsed successfully."); clearTimeout(timeoutID); resolve(g); } catch (e) { clearTimeout(timeoutID); reject(e); } }); } /** * Asynchronously convert a CSSStyleSheet to a string. * @param {CSSStyleSheet} styleSheet The CSSStyleSheet to convert. * @returns {Promise<string>} A promise that resolves to the CSSStyleSheet as a string. */ async function asyncCss2String(styleSheet) { return new Promise((resolve, reject) => { try { resolve(Array.from(styleSheet.cssRules) .map(rule => rule.cssText) // Don't include hover rules (The collapse/expand doesn't work in SVGs anyway) .filter(rule => !rule.includes("hover")) .join("\n")); } catch (e) { reject(e); } }); } /** * Convert a base64 encoded string to a base64URL encoded string. * @param {string} base64 The base64 encoded string to convert * @returns {string} The base64URL encoded string */ function base64ToBase64Url(base64) { return base64 .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } /** * Convert a base64URL encoded string to a base64 encoded string. * @param {string} base64Url The base64URL encoded string to convert * @returns {string} The base64 encoded string */ function base64UrlToBase64(base64Url) { let base64 = base64Url .replace(/-/g, '+') .replace(/_/g, '/'); while (base64.length % 4 !== 0) { base64 += '='; } return base64; } /** * Check if a string is ASCII. * @param {string} str The string to check * @param {boolean} extended If `true`, the string can contain extended ASCII characters (0-255), otherwise only 0-127 * @returns {Set<string> A set of non-ASCII characters} */ function getNonAsciiChars(str, extended = false) { return new Set(str .split('') .filter(char => char.charCodeAt(0) > (extended ? 255 : 127))); } /** * Converts the string used for the path in svg titels into an array of numbers representing the path. * @param {string} title The title string to convert * @returns {number[]} The path as an array of numbers */ function title2Path(title) { return title.split('-').map(Number); } /** * Add the values of the grammar and expand paths to a URL. * The values are added as base64URL encoded strings. If the values are over a certain threshold, they are compressed using LZString. If they are below the threshold, they are only encoded in order to avoid the overhead of compression. * If a value is empty, it is removed from the URL. If a value is not provided, it will not be added/removed, nor will it be updated. * @param {string} urlHref The URL to add the values to * @param {string} grammar The grammar * @param {Set<number[]>} expandPath The expand paths * @param {string} startSymbolName The name of the start symbol * @returns {URL} The URL with the values added */ async function addValuesToUrl(urlHref, grammar, expandPath, startSymbolName) { const newUrl = new URL(urlHref); if (startSymbolName && startSymbolName.trim() !== "") { newUrl.searchParams.delete(START_SYMBOL_PARAM); if (startSymbolName.trim() !== "") { newUrl.searchParams.set(START_SYMBOL_PARAM, startSymbolName); } } if (grammar) { newUrl.searchParams.delete(COMPRESSED_GRAMMAR_PARAM_LZ); newUrl.searchParams.delete(COMPRESSED_GRAMMAR_PARAM_GZIP); newUrl.searchParams.delete(GRAMMAR_PARAM); if (grammar.trim() !== "") { if (grammar.length > COMPRESSION_THRESHOLD) { // Compress the grammar const compressedGrammar = await compressAndEncode(grammar); newUrl.searchParams.set(COMPRESSED_GRAMMAR_PARAM_GZIP, base64ToBase64Url(compressedGrammar)); } else { // Set the grammar const base64Grammar = btoa(grammar); newUrl.searchParams.set(GRAMMAR_PARAM, base64ToBase64Url(base64Grammar)); } } } if (expandPath) { newUrl.searchParams.delete(COMPRESSED_EXPAND_PARAM_LZ); newUrl.searchParams.delete(COMPRESSED_EXPAND_PARAM_GZIP); newUrl.searchParams.delete(EXPAND_PARAM); if (expandPath.size !== 0) { const joinedPath = Array.from(expandPath).map(path => path.join("-")).join("|"); if (joinedPath.length > COMPRESSION_THRESHOLD) { // Compress the expand paths const compressedExpand = await compressAndEncode(joinedPath); debugger; newUrl.searchParams.set(COMPRESSED_EXPAND_PARAM_GZIP, base64ToBase64Url(compressedExpand)); } else { // Set the expand paths const base64Expand = btoa(joinedPath); newUrl.searchParams.set(EXPAND_PARAM, base64ToBase64Url(base64Expand)); } } } return newUrl; } /** * Get the values of the grammar and expand paths from a URL. * The counterpart to `addValuesToUrl`. * @param {string} searchParams The search parameters of the URL * @returns The grammar, expanded paths and start symbol name */ export async function getValuesFromUrl(searchParams) { const urlSearchparams = new URLSearchParams(searchParams); let expandPaths = new Set(); const startSymbolName = urlSearchparams.get(START_SYMBOL_PARAM) || ""; // Grammar const GzipCompressedGrammar = urlSearchparams.get(COMPRESSED_GRAMMAR_PARAM_GZIP); const LzCompressedGrammar = urlSearchparams.get(COMPRESSED_GRAMMAR_PARAM_LZ); const base64Grammar = urlSearchparams.get(GRAMMAR_PARAM); let grammar = ""; if (GzipCompressedGrammar) { console.debug("Fetching grammar from Gzip"); try { grammar = await decodeAndDecompress(base64UrlToBase64(GzipCompressedGrammar)); } catch (e) { console.error("Decompression failed!", e); } } else if (LzCompressedGrammar) { console.debug("Falling back to old compression library"); try { await fetchLzString(); // Compressed grammar const uncompressedGrammar = window.LZString?.decompressFromBase64(base64UrlToBase64(LzCompressedGrammar)); if (uncompressedGrammar == null) { throw new Error("Decompression failed!"); } grammar = uncompressedGrammar; } catch (e) { console.error("URL uses old compression library, but the library could not be loaded!", e); } } else if (base64Grammar) { // Uncompressed grammar grammar = atob(base64UrlToBase64(base64Grammar)); } // Expand paths const GzipCompressedExpand = urlSearchparams.get(COMPRESSED_EXPAND_PARAM_GZIP); const LzCompressedExpand = urlSearchparams.get(COMPRESSED_EXPAND_PARAM_LZ); const base64Expand = urlSearchparams.get(EXPAND_PARAM); let expandPathString = undefined; if (GzipCompressedExpand) { console.debug("Fetching expanded paths from Gzip"); try { expandPathString = await decodeAndDecompress(base64UrlToBase64(GzipCompressedExpand)); } catch (e) { console.error("Decompression failed!", e); } } else if (LzCompressedExpand) { console.warn("Falling back to old compression library"); // Compressed expand paths try { if (window.LZString == null) { await fetchLzString(); } const uncompressedExpand = window.LZString?.decompressFromBase64(base64UrlToBase64(LzCompressedExpand)); if (uncompressedExpand == null) { throw new Error("Decompression failed"); } expandPathString = uncompressedExpand; } catch (e) { console.error("URL uses old compression library, but the library could not be loaded!", e); } } else if (base64Expand) { // Uncompressed expand paths expandPathString = atob(base64UrlToBase64(base64Expand)); } if (expandPathString) { expandPaths = new Set(expandPathString.split("|").map(path => path.split("-").map(Number))); } return [grammar, expandPaths, startSymbolName]; } /** * Filter out invalid paths from a set of paths. * @param {string} grammar The grammar to find the paths in * @param {Set<number[]>} paths The current paths * @returns {Set<number[]>} The current paths with invalid paths removed */ function filterInvalidPaths(grammar, paths) { const validPaths = new Set(); const allPaths = Array.from(Diagram.fromString(grammar).getAllNtsPaths()); // Return a "union" of all paths and the provided paths for (const path of paths) { if (allPaths.some(p => p.every((v, i) => v === path[i]))) { validPaths.add(path); } } return validPaths; } /** * Get the SVG of the diagram. * This does not reuse the diagram shown in the UI, but generates a new one to get a clean SVG. * @param {Set<number[]>} toExpand The paths to expand * @returns The SVG as a svg+xml string */ function getSvg(toExpand) { return new Promise((resolve, reject) => { const ebnfGrammarValue = document.querySelector("textarea[name=ebnf_grammar]")?.value; if (ebnfGrammarValue.trim() === "") { console.debug("Nothing was entered into the textarea."); reject("No grammar to visualize"); } // Generate new diagram to get clean SVG asyncString2Diagram(ebnfGrammarValue, chooChoo.currentStartSymbolName).then((diagram) => { let svgHtml = diagram.toSvg(toExpand); const additionalAttributes = [ 'xmlns="http://www.w3.org/2000/svg"', 'shape-rendering="geometricPrecision"', 'text-rendering="geometricPrecision"', 'image-rendering="optimizeQuality"', ]; // Add additional attributes to the SVG svgHtml = svgHtml.replace("<svg", `<svg ${additionalAttributes.join(" ")}`); // Get the style of the diagram (./css/railroad.css) const styleSheet = document.styleSheets[0]; asyncCss2String(styleSheet).then((cssString) => { // Add the CSS to the SVG as a style element svgHtml = svgHtml.replace("</defs>", `<style>${cssString}</style></defs>`); resolve(svgHtml); }).catch((e) => { reject(e); }); }); }); } /** * Export the diagram as an SVG file. * @param {Set<number[]>} toExpand The paths to expand, if not provided the current paths are used */ export async function exportSvg(toExpand) { toExpand = toExpand || chooChoo.toExtend; const exportAsSvgButton = document.querySelector("#export-as-svg"); buttonPressLoad(exportAsSvgButton); getSvg(toExpand).then((svgHtml) => { // Save SVG HTML as file const blob = new Blob([svgHtml], { type: "image/svg+xml" }); const blobUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = blobUrl; a.download = "railroad-diagram.svg"; a.click(); URL.revokeObjectURL(blobUrl); buttonPressReset(exportAsSvgButton); }); } /** * Export the diagram as a PNG file. * @param {Set<number[]>} toExpand The paths to expand, if not provided the current paths are used */ export async function exportPng(toExpand) { toExpand = toExpand || chooChoo.toExtend; const exportAsPngButton = document.querySelector("#export-as-png"); buttonPressLoad(exportAsPngButton); getSvg(toExpand).then((svgHtml) => { // Get width and height of the SVG from svgs width and height attributes const origWidth = parseFloat(svgHtml.match(/width="([^"]+)"/)?.[1] || "0"); const origHeight = parseFloat(svgHtml.match(/height="([^"]+)"/)?.[1] || "0"); // Scale the SVG but keep the aspect ratio const aspectRatio = origWidth / origHeight; const scaledWidth = (origWidth > origHeight) ? (origWidth * PNG_EXPORT_SCALE_FACTOR) : (origHeight * PNG_EXPORT_SCALE_FACTOR * aspectRatio); const scaledHeight = (origWidth > origHeight) ? (origWidth * PNG_EXPORT_SCALE_FACTOR / aspectRatio) : (origHeight * PNG_EXPORT_SCALE_FACTOR); console.debug(`Scaled SVG to ${scaledWidth}x${scaledHeight}px for PNG export.`); // Update the SVG width and height const scaledSvgHtml = svgHtml .replace(`width="${origWidth}"`, `width="${scaledWidth}"`) .replace(`height="${origHeight}"`, `height="${scaledHeight}"`); // Create blob from SVG and use it to create a data URL const blob = new Blob([scaledSvgHtml], { type: "image/svg+xml;charset=utf-8" }); const blobUrl = URL.createObjectURL(blob); // Convert the SVG to a PNG using a canvas const img = new Image(); img.src = blobUrl; img.onload = function () { const canvas = document.createElement("canvas"); canvas.width = scaledWidth; canvas.height = scaledHeight; const ctx = canvas.getContext("2d"); if (!ctx) { console.error("Canvas not supported"); return; } ctx.drawImage(img, 0, 0); // Save the PNG const pngUrl = canvas.toDataURL("image/png"); const a = document.createElement("a"); a.href = pngUrl; a.download = "railroad-diagram.png"; a.click(); URL.revokeObjectURL(pngUrl); buttonPressReset(exportAsPngButton); }; img.onerror = function () { console.error("Error loading SVG image"); console.log(img.src); buttonPressReset(exportAsPngButton); }; }).catch((e) => { console.error(e); }); } /** * Set a spinner cursor and disable a button. * @param {HTMLButtonElement} button The button to disable */ function buttonPressLoad(button) { button.style.cursor = "wait"; button.disabled = true; } /** * Reset the cursor and enable a button. * @param {HTMLButtonElement} button The button to enable */ function buttonPressReset(button) { button.style.cursor = "default"; button.disabled = false; } /** * Update the URL with the current grammar and expand paths. * @param {string} [startSymbol] The name of the current start symbol */ async function updateUrl(startSymbol) { const grammar = document.querySelector("textarea[name=ebnf_grammar]"); if (!grammar) return; const newUrl = await addValuesToUrl(location.href, grammar.value, chooChoo.toExtend, startSymbol ?? chooChoo.currentStartSymbolName); // Replace URL window.history.replaceState({}, "", newUrl); } /** * Update the Viewbox size of the SVG. */ function updateSvgViewBoxSize() { const diagSvg = document.getElementsByClassName("railroad-diagram")[0]; if (!diagSvg) return; const flexContainer = document.getElementsByClassName("flex-container")[0].children; const totalHeightNonEbnfElems = Array.from(flexContainer).slice(0, flexContainer.length - 1).reduce((acc, elem) => acc + elem.clientHeight, 0); const leftoverHeight = window.innerHeight - totalHeightNonEbnfElems; // Check if parent element is present if (!diagSvg.parentElement) return; // FIXME: The height is messed up. Therefore the centering feature is off on the vertical axis. diagSvg.setAttribute("viewBox", `0 0 ${diagSvg.parentElement.offsetWidth} ${leftoverHeight}`); } /** * Update the start symbols in the dropdown. * @param {Array<string>} startSymbols The start symbols to set * @param {string} currentStartSymbolName The name of the current start symbol * @returns {string|undefined} - The name of the current start symbol */ function setStartSymbols(startSymbols, currentStartSymbolName) { if (!startSymbolDropDown) { console.warn("Failed to find start symbol drop down."); return undefined; } if (startSymbols.length === 0) { startSymbolDropDown.style.display = "none"; console.warn("Start symbol selection is of size 0"); return undefined; } const firstSymbol = startSymbols[0]; startSymbols.sort(); startSymbols = startSymbols.sort(); const startSymbolSelect = document.querySelector("#start-symbol"); if (!startSymbolSelect) { console.warn("Failed to find start symbol select."); return undefined; } // Check if the list is up-to-date (length and item wise) if (startSymbolSelect.options.length === startSymbols.length) { let allMatch = true; for (let i = 0; i < startSymbols.length; i++) { if (startSymbolSelect.options[i].value !== startSymbols[i]) { allMatch = false; break; } } if (allMatch) { return currentStartSymbolName; } } console.debug("Updating start symbols in dropdown."); startSymbolSelect.innerHTML = ""; startSymbols.forEach((startSymbol) => { const option = document.createElement("option"); option.value = startSymbol; option.text = startSymbol; startSymbolSelect.appendChild(option); }); startSymbolDropDown.style.display = "block"; // Check if current start symbol is in the list if (startSymbols.includes(currentStartSymbolName)) { // Keep the current start symbol startSymbolSelect.value = currentStartSymbolName; return currentStartSymbolName; } else { // Use the first start symbol as fallback startSymbolSelect.value = firstSymbol; updateUrl(firstSymbol); return firstSymbol; } } /** * Handle the selection of a new start symbol. */ function handleStartSymbolSelection() { const startSymbolSelect = document.querySelector("#start-symbol"); if (startSymbolSelect == null) { throw new Error("Failed to find start symbol select."); } const startSymbol = startSymbolSelect.value; if (startSymbol === chooChoo.currentStartSymbolName) { return; } console.debug(`Setting start symbol to '${chooChoo.currentStartSymbolName}' and creating new diagram.`); chooChoo.currentStartSymbolName = startSymbol; // Clear "chooChoo.toExtend" as start symbol changed chooChoo.toExtend.clear(); generateDiagram(); updateUrl(); } /** * Fetches the lz-string library on demand and puts it into the global scope. * Only needed if the URL contains a compressed value with the old library. */ function fetchLzString() { console.warn("Loading lz-string dynamically. Consider updating the URL"); return new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = "https://cdn.jsdelivr.net/gh/pieroxy/lz-string@1.5.0/libs/lz-string.min.js"; script.onload = () => resolve(); script.onerror = reject; document.head.appendChild(script); }); } //# sourceMappingURL=ChooChoo.js.map