UNPKG

@components-1812/json-visualizer

Version:

A web component for visualizing JSON data in a collapsible tree view.

435 lines (318 loc) 10.7 kB
import {JSONLine} from "./JSONLine.js"; import {JSONBlock} from "./JSONBlock.js"; import JSONTokenizer from "./JSONTokenizer.js"; import { CopyButton } from "./CopyButton.js"; export class JSONVisualizer extends HTMLElement { static version = "0.0.2"; /** * @type {{links:string[], adopted:CSSStyleSheet[], raw:string[]}} Stylesheets to be applied to the component */ static stylesSheets = { links: [], adopted: [], raw: [], }; /** * Asynchronous function that tokenizes a JSON string. * @async * @param {string} rawJson - The input JSON string. * @returns {Promise<Array<import("./JSONTokenizer.js").Token>>} Array of tokens. */ static getTokens = async (rawJson) => { const tokenizer = new JSONTokenizer(); tokenizer.tokenize(rawJson); return tokenizer.tokens; }; static defaults = { renderDeep: Infinity } #rootBlock = null; #copyButton = null; #data = null; constructor() { super(); this.attachShadow({ mode: "open" }); this.shadowRoot.innerHTML = ` <div class="JSONVisualizer"></div> <slot name="icons"> <template> <svg data-name="toggle" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/> </svg> <svg data-name="copy" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/> </svg> <svg data-name="copy-done" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0"/> </svg> </template> </slot> `; //MARK: Styles managment Promise.allSettled( JSONVisualizer.stylesSheets.links.map((styleSheet) => { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = styleSheet; const { promise, resolve, reject } = Promise.withResolvers(); link.addEventListener("load", () => resolve({ link, href: styleSheet, status: "loaded" })); link.addEventListener("error", () => reject({ link, href: styleSheet, status: "error" })); this.shadowRoot.prepend(link); return promise; }) ).then((results) => { this.dispatchEvent( new CustomEvent("ready-links", { detail: { results: results.map((r) => r.value || r.reason) }, }) ); this.setAttribute("ready-links", ""); }); JSONVisualizer.stylesSheets.raw.forEach((style) => { const styleElement = document.createElement("style"); styleElement.textContent = style; this.shadowRoot.prepend(styleElement); }); this.shadowRoot.adoptedStyleSheets = JSONVisualizer.stylesSheets.adopted; } //MARK: callback lifecycle static observedAttributes = ['json', 'src']; attributeChangedCallback(name, oldValue, newValue){ if(name === 'src' && newValue !== oldValue){ this.loadJSON(newValue); } if(name === 'json' && newValue !== oldValue){ this.renderJSON(newValue); } } connectedCallback() { if(!this.src && !this.json){ this.json = this.textContent; } if(this.copyButton !== 'none'){ this.#copyButton = new CopyButton({ data: () => this.data, icons: { copy: this.getIcon('copy', {clone: true}), copyDone: this.getIcon('copy-done', {clone: true}) } }); this.shadowRoot.append( this.#copyButton.render() ); } this.addEventListener("toggle-lines", this.#handletoggleLines); this.dispatchEvent(new CustomEvent("ready")); this.setAttribute("ready", ""); } disconnectedCallback(){ this.#copyButton?.dispose(); this.#copyButton = null; this.clearJSON(); this.clearListeners(); } //MARK: loadJSON async loadJSON(src, options = {}){ const { method, } = options; try { const response = await fetch(src, { method: method || 'GET', }); if(response.ok){ const result = await response.json(); this.json = result; } } catch (error) { } } //MARK: createJSONLines async #createJSONLines(rawJSON){ if (!JSONVisualizer.getTokens) { console.warn(`JSONVisualizer.getTokens is not defined. Please ensure that the JSONTokenizer`); return; } const lines = []; const tokens = await JSONVisualizer.getTokens(rawJSON); let currentLine = null; let level = 0; let lineNumber = 1; for (let i = 0; i < tokens.length; i++) { const { type, value, tags } = tokens.at(i); if(["brace-close", "bracket-close"].includes(type)){ level--; //Se asegura de que } y ] siempre esten en una nueva linea const previousToken = tokens.at(i - 1); if ( !["brace-open", "bracket-open", "comma"].includes(previousToken?.type) ) { currentLine = null; } }; //Crear una nueva linea si no existe if(!currentLine) { currentLine = new JSONLine({ level, number: lineNumber++ }); lines.push(currentLine); } currentLine.addToken({ type, value, tags }); if(["brace-close", "bracket-close"].includes(type)) { const nextToken = tokens.at(i + 1); if (nextToken?.type === "comma") { //Agregar la coma en la misma línea currentLine.addToken(nextToken); //Saltar el token de coma y crear una nueva linea i++; currentLine = null; continue; } } if(["brace-open", "bracket-open"].includes(type)){ level++; }; //Crea un nueva linea if(["brace-open","brace-close","bracket-open","bracket-close","comma"].includes(type)){ currentLine = null; } } return lines; } //MARK: RenderJSON async renderJSON(rawJSON, config = {}) { if (!rawJSON) { console.warn(`No JSON provided to renderJSON method.`); return; } console.log('render json'); this.clearJSON(); const { lineNumbers = this.lineNumbers !== 'none', toggleLines = this.toggleLines !== 'none', renderDeep = this.renderDeep, } = config; //Create Lines const lines = await this.#createJSONLines(rawJSON); const blocksStack = []; for(let i = 0; i < lines.length; i++){ const line = lines.at(i); line.showNumber = lineNumbers; if(line.isOpenBlock){ const block = new JSONBlock({ level: line.level, showContent: line.level < renderDeep }); block.openLine = line; line.block = block; if(toggleLines){ line.toggleControl = true; line.toggleActive = !block.showContent; line.toggleIcon = this.getIcon('toggle', {clone: true}); } blocksStack.push(block); continue; } if(line.isCloseBlock){ const block = blocksStack.pop(); block.closeLine = line; line.block = block; // estamos dentro de otro bloque → anidarlo if (blocksStack.length > 0) { blocksStack.at(-1).content.push(block); } else { this.#rootBlock = block; } continue; } const currentBlock = blocksStack.at(-1); line.block = currentBlock; currentBlock.content.push(line); } this.shadowRoot.querySelector(".JSONVisualizer").append( this.#rootBlock.render() ); //Line number width if(lineNumbers){ const minWidth = `${String(lines.length).length}ch`; this.shadowRoot.querySelector(".JSONVisualizer").style.setProperty("--line-number-min-width", minWidth); } this.setAttribute('ready-json', ''); this.dispatchEvent(new CustomEvent('ready-json')); } getIcon(name, {clone = false} = {}){ const slot = this.shadowRoot.querySelector(`slot[name="icons"]`); const defaultIcons = slot.querySelector('template').content; const icons = slot.assignedNodes().at(0)?.content; const icon = icons?.querySelector(`[data-name="${name}"]`) ?? defaultIcons.querySelector(`[data-name="${name}"]`); return clone ? icon.cloneNode(true) : icon; } //MARK: Clear clearJSON(){ this.removeAttribute('ready-json'); this.#rootBlock?.dispose(); this.#rootBlock = null; } clearListeners(){ this.removeEventListener("toggle-lines", this.#handletoggleLines); } //MARK: Toggle Lines #handletoggleLines = (e) => { const {line} = e.detail; if(!line.block.showContent){ line.block.showContent = true; line.block.renderContent(); } else { line.block.folded ? line.block.unfold() : line.block.fold(); } } //MARK: Getters and Setters set json(value) { if(value) { this.#data = typeof value === "string" ? JSON.parse(value) : value; this.setAttribute("json", JSON.stringify(this.#data)); } else { this.removeAttribute("json"); this.#data = null; } } get json() { return this.getAttribute("json"); } get data() { return this.#data; } set lineNumbers(value) { value ? this.setAttribute("line-numbers", "") : this.removeAttribute("line-numbers"); } get lineNumbers() { return this.getAttribute("line-numbers"); } set toggleLines(value) { value ? this.setAttribute("toggle-lines", "") : this.removeAttribute("toggle-lines"); } get toggleLines() { return this.getAttribute("toggle-lines"); } set indentationGuidesLines(value) { value ? this.setAttribute("indentation-guides-lines", "") : this.removeAttribute("indentation-guides-lines"); } get indentationGuidesLines() { return this.getAttribute("indentation-guides-lines"); } set copyButton(value) { value ? this.setAttribute("copy-button", "") : this.removeAttribute("copy-button"); } get copyButton() { return this.getAttribute("copy-button"); } set renderDeep(value){ value ? this.setAttribute("render-deep", value) : this.removeAttribute("render-deep"); } get renderDeep(){ const value = this.getAttribute('render-deep'); if(!value || Number.isNaN(Number(value))) return JSONVisualizer.defaults.renderDeep; return Number(value); } set src(value){ value ? this.setAttribute("src", value) : this.removeAttribute("src"); } get src(){ return this.getAttribute("src"); } } export default JSONVisualizer;