UNPKG

ngx-json-treeview

Version:

A simple Angular component to display object data in an expandable JSON tree view.

361 lines (355 loc) 26.8 kB
import * as i0 from '@angular/core'; import { input, output, computed, Component } from '@angular/core'; /** * Generates a preview string representation of an object. * * @param obj The object to preview. * @param limit The maximum length of the preview string. Defaults to 200. * @param stringsLimit The maximum length of a string to display before * truncating. Defaults to 10. * @returns A preview string representation of the object. */ function previewString(obj, limit = 200, stringsLimit = 10) { let result = ''; if (obj === null) { result += 'null'; } else if (obj === undefined) { result += 'undefined'; } else if (typeof obj === 'string') { if (obj.length > stringsLimit) { result += `"${obj.substring(0, stringsLimit)}…"`; } else { result += `"${obj}"`; } } else if (typeof obj === 'boolean') { result += `${obj ? 'true' : 'false'}`; } else if (typeof obj === 'number') { result += `${obj}`; } else if (typeof obj === 'object') { if (obj instanceof Date) { result += `"${obj.toISOString()}"`; } else if (Array.isArray(obj)) { result += `Array[${obj.length}] [`; for (const key in obj) { if (result.length >= limit) { break; } result += previewString(obj[key], limit - result.length); result += ','; } if (result.endsWith(',')) { result = result.slice(0, -1); } result += ']'; } else { result += 'Object {'; for (const key in obj) { if (result.length >= limit) { break; } if (obj[key] !== undefined) { result += `"${key}":`; result += previewString(obj[key], limit - result.length); result += ','; } } if (result.endsWith(',')) { result = result.slice(0, -1); } result += '}'; } } else if (typeof obj === 'function') { result += 'Function'; } if (result.length >= limit) { return result.substring(0, limit) + ''; } return result; } /** * Decycles a JavaScript object by replacing circular references with `$ref` * properties. This is useful for serializing objects that contain circular * references, preventing infinite loops. * * Original: https://github.com/douglascrockford/JSON-js/blob/master/cycle.js * * @param object The object to decycle. * @returns A decycled version of the object. */ function decycle(object) { const objects = new WeakMap(); return (function derez(value, path) { let old_path; let nu; if (typeof value === 'object' && value !== null && !(value instanceof Boolean) && !(value instanceof Date) && !(value instanceof Number) && !(value instanceof RegExp) && !(value instanceof String)) { old_path = objects.get(value); if (old_path !== undefined) { return { $ref: old_path }; } objects.set(value, path); if (Array.isArray(value)) { nu = []; value.forEach(function (element, i) { nu[i] = derez(element, path + '[' + i + ']'); }); } else { nu = {}; Object.keys(value).forEach(function (name) { nu[name] = derez(value[name], path + '[' + JSON.stringify(name) + ']'); }); } return nu; } return value; })(object, '$'); } /** * Renders JSON data in an expandable and collapsible tree structure. * Allows users to navigate complex data hierarchies visually. */ class NgxJsonTreeviewComponent { /** * The JSON object or array to display in the tree view. * @required */ json = input.required(); /** * Controls the default expansion state for all expandable segments * i.e. objects and arrays. * - If `true`, nodes are expanded down to the specified `depth`. * - If `false`, all nodes start collapsed. * @default true */ expanded = input(true); /** * Determines the maximum nesting level automatically expanded when `expanded` * is `true`. * - `-1`: Infinite depth (all levels expanded). * - `0`: Only the root node is expanded (if applicable). * - `n`: Root and nodes down to `n` levels are expanded. * @default -1 */ depth = input(-1); /** * If `true`, value nodes will emit an `onValueClick` event when clicked. This * allows for some interesting use cases, such as: * - Rendering preformatted text, html, markdown, etc in another component. * - Copying a value to the clipboard. * - Following hyperlinks, etc * @default false */ enableClickableValues = input(false); /** * A function that determines if a specific value node should be considered * clickable. This provides more granular control than the global * `enableClickableValues` flag. * * The function receives the `Segment` object and should return `true` if the * value is clickable, `false` otherwise. This check is only performed if * `enableClickableValues` is also `true`. * * @param segment - The segment being evaluated. * @returns `true` if the segment's value should be clickable, `false` * otherwise. * @default () => true - By default, all values are considered clickable if * `enableClickableValues` is true. */ isClickableValue = input(() => true); /** * If `enableClickableValues` is set to `true`, emits a `Segment` object when * a value node is clicked. The emitted `Segment` contains details about the * clicked node (key, value, type, path, etc.). */ onValueClick = output(); /** * *Internal* input representing the parent segment in the tree hierarchy. * Primrily used for calculating paths. * @internal */ _parent = input(); /** * *Internal* input representing the current nesting depth. Used in * conjunction with the `depth` input to control expansion. * @internal */ _currentDepth = input(0); rootType = computed(() => { if (this.json() === null) { return 'null'; } else if (Array.isArray(this.json())) { return 'array'; } else return typeof this.json(); }); segments = computed(() => { const json = decycle(this.json()); if (typeof json === 'object' && json != null) { return Object.keys(json).map((key) => this.parseKeyValue(key, json[key])); } return []; }); isExpanded = computed(() => this.expanded() && !(this.depth() > -1 && this._currentDepth() >= this.depth())); openingBrace = computed(() => { if (this.rootType() === 'array') { return '['; } else return '{'; }); closingBrace = computed(() => { if (this.rootType() === 'array') { return ']'; } else return '}'; }); asString = computed(() => JSON.stringify(this.json(), null, 2).trim()); primativeSegmentClass = computed(() => { const type = this.rootType(); if (['object', 'array'].includes(type)) { return 'punctuation'; } return 'segment-type-' + type; }); isArrayElement = computed(() => this.rootType() === 'array'); isExpandable(segment) { return ((segment.type === 'object' && Object.keys(segment.value).length > 0) || (segment.type === 'array' && segment.value.length > 0)); } isEmpty(segment) { return ((segment.type === 'object' && Object.keys(segment.value).length === 0) || (segment.type === 'array' && segment.value.length === 0)); } isClickable(segment) { return this.enableClickableValues() && this.isClickableValue()(segment); } toggle(segment) { if (this.isExpandable(segment)) { segment.expanded = !segment.expanded; } } onValueClickHandler(segment) { if (this.isClickable(segment)) { this.onValueClick.emit(segment); console.debug(`onValueClick: ${segment.path}`); } } openingBraceForSegment(segment) { if (segment.type === 'array') { return '['; } else if (segment.type === 'object') { return '{'; } return undefined; } closingBraceForSegment(segment) { if (segment.type === 'array') { return ']'; } else if (segment.type === 'object') { return '}'; } return undefined; } getPath(key) { const parent = this._parent(); let path; if (parent) { if (parent.type === 'array') { path = `${parent.path}[${key}]`; } else { path = `${parent.path}.${key}`; } } else { path = key; } return path; } parseKeyValue(key, value) { const segment = { parent: this._parent(), path: this.getPath(key), key: key, value: value, type: undefined, description: '' + value, expanded: this.isExpanded(), }; switch (typeof segment.value) { case 'number': segment.type = 'number'; break; case 'boolean': segment.type = 'boolean'; break; case 'function': segment.type = 'function'; break; case 'string': segment.type = 'string'; segment.description = JSON.stringify(segment.value); break; case 'undefined': segment.type = 'undefined'; segment.description = 'undefined'; break; case 'object': if (segment.value === null) { segment.type = 'null'; segment.description = 'null'; } else if (Array.isArray(segment.value)) { segment.type = 'array'; segment.description = previewString(segment.value); } else if (segment.value instanceof Date) { segment.type = 'date'; segment.description = `"${segment.value.toISOString()}"`; } else { segment.type = 'object'; segment.description = previewString(segment.value); } break; default: console.error('Unknown type parsing json key/value.'); } return segment; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.1", ngImport: i0, type: NgxJsonTreeviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.1", type: NgxJsonTreeviewComponent, isStandalone: true, selector: "ngx-json-treeview", inputs: { json: { classPropertyName: "json", publicName: "json", isSignal: true, isRequired: true, transformFunction: null }, expanded: { classPropertyName: "expanded", publicName: "expanded", isSignal: true, isRequired: false, transformFunction: null }, depth: { classPropertyName: "depth", publicName: "depth", isSignal: true, isRequired: false, transformFunction: null }, enableClickableValues: { classPropertyName: "enableClickableValues", publicName: "enableClickableValues", isSignal: true, isRequired: false, transformFunction: null }, isClickableValue: { classPropertyName: "isClickableValue", publicName: "isClickableValue", isSignal: true, isRequired: false, transformFunction: null }, _parent: { classPropertyName: "_parent", publicName: "_parent", isSignal: true, isRequired: false, transformFunction: null }, _currentDepth: { classPropertyName: "_currentDepth", publicName: "_currentDepth", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { onValueClick: "onValueClick" }, ngImport: i0, template: "<section class=\"ngx-json-treeview\">\n @if (segments().length === 0) {\n <div [class]=\"primativeSegmentClass()\">\n <span class=\"segment-primitive\">\n {{ asString() }}\n </span>\n </div>\n } @else {\n @if (_currentDepth() === 0) {\n <span class=\"punctuation\">{{ openingBrace() }}</span>\n }\n @for (\n segment of segments();\n track segment;\n let i = $index;\n let len = $count\n ) {\n @let needsComma = i < len - 1;\n @let expandable = isExpandable(segment);\n @let empty = isEmpty(segment);\n @let openingBrace = openingBraceForSegment(segment);\n @let closingBrace = closingBraceForSegment(segment);\n @let clickableValue = isClickable(segment);\n <div [class]=\"'segment segment-type-' + segment.type\">\n <div class=\"segment-main\">\n <span\n [tabindex]=\"expandable ? 0 : -1\"\n [class.expandable]=\"expandable\"\n [class.expanded]=\"segment.expanded\"\n (click)=\"toggle(segment)\"\n (keydown.enter)=\"toggle(segment)\">\n @if (expandable) {\n <div class=\"toggler\"></div>\n }\n @if (isArrayElement()) {\n <span class=\"segment-label\">{{ segment.key }}</span>\n } @else {\n <span class=\"segment-key\">{{ `\"${segment.key}\"` }}</span>\n }\n </span>\n <span\n [class.segment-label]=\"isArrayElement()\"\n [class.punctuation]=\"!isArrayElement()\"\n >:\n </span>\n @if (empty) {\n <span class=\"punctuation\"\n >{{ openingBrace }}{{ closingBrace\n }}{{ needsComma ? ',' : '' }}</span\n >\n } @else if (!expandable || !segment.expanded) {\n <span\n [tabindex]=\"clickableValue ? 0 : -1\"\n [class.segment-label]=\"expandable\"\n [class.segment-value]=\"!expandable\"\n [class.clickable]=\"clickableValue\"\n (click)=\"onValueClickHandler(segment)\"\n (keydown.enter)=\"onValueClickHandler(segment)\"\n >{{ segment.description }}</span\n >\n <span class=\"punctuation\">{{ needsComma ? ',' : '' }}</span>\n } @else {\n <span class=\"punctuation\">\n {{ openingBrace }}\n </span>\n }\n </div>\n @if (expandable && segment.expanded) {\n <div class=\"children\">\n <ngx-json-treeview\n [json]=\"segment.value\"\n [expanded]=\"expanded()\"\n [depth]=\"depth()\"\n [isClickableValue]=\"isClickableValue()\"\n [enableClickableValues]=\"enableClickableValues()\"\n [_parent]=\"segment\"\n [_currentDepth]=\"_currentDepth() + 1\"\n (onValueClick)=\"onValueClickHandler($event)\" />\n <span class=\"punctuation\">\n {{ closingBrace }}{{ needsComma ? ',' : '' }}\n </span>\n </div>\n }\n </div>\n }\n @if (_currentDepth() === 0) {\n <span class=\"punctuation\">{{ closingBrace() }}</span>\n }\n }\n</section>\n", styles: ["@charset \"UTF-8\";.ngx-json-treeview{font-family:var(--ngx-json-font-family, monospace);font-size:var(--ngx-json-font-size, 1em);overflow:hidden;position:relative}.ngx-json-treeview .segment{margin:1px 1px 1px 12px;padding:2px}.ngx-json-treeview .segment .segment-main{word-wrap:break-word;margin:1px 1px 1px 12px}.ngx-json-treeview .segment .segment-main .toggler{color:var(--ngx-json-toggler, #787878);font-size:.8em;line-height:1.2em;margin-left:-14px;margin-top:3px;position:absolute;vertical-align:middle}.ngx-json-treeview .segment .segment-main .toggler:after{content:\"\\25b6\";display:inline-block;transition:transform .1s ease-in}.ngx-json-treeview .segment .segment-main .segment-key{color:var(--ngx-json-key, #a31515)}.ngx-json-treeview .segment .segment-main .segment-label{color:var(--ngx-json-label, #656e77);font-style:italic}.ngx-json-treeview .segment .segment-main .segment-value{color:var(--ngx-json-value, #000)}.ngx-json-treeview .segment .children{margin-left:12px}.ngx-json-treeview .segment-type-string>.segment-main>.segment-value{color:var(--ngx-json-string, #0451a5)}.ngx-json-treeview .segment-type-number>.segment-main>.segment-value{color:var(--ngx-json-number, #098658)}.ngx-json-treeview .segment-type-boolean>.segment-main>.segment-value{color:var(--ngx-json-boolean, #a31515)}.ngx-json-treeview .segment-type-date>.segment-main>.segment-value{color:var(--ngx-json-date, #05668d)}.ngx-json-treeview .segment-type-function>.segment-main>.segment-value{color:var(--ngx-json-function, #656e77)}.ngx-json-treeview .segment-type-null>.segment-main>.segment-value{color:var(--ngx-json-null, #fff)}.ngx-json-treeview .segment-type-undefined>.segment-main>.segment-value{color:var(--ngx-json-undefined, #fff)}.ngx-json-treeview .segment-type-null>.segment-main>.segment-value{background-color:var(--ngx-json-null-bg, red)}.ngx-json-treeview .segment-type-undefined>.segment-main>.segment-key{color:var(--ngx-json-undefined-key, #a31515)}.ngx-json-treeview .segment-type-undefined>.segment-main>.segment-value{background-color:var(--ngx-json-undefined-bg, #656e77)}.ngx-json-treeview .segment-type-object>.segment-main,.ngx-json-treeview .segment-type-array>.segment-main{white-space:nowrap}.ngx-json-treeview .expanded>.toggler:after{transform:rotate(90deg)}.ngx-json-treeview .expandable,.ngx-json-treeview .expandable>.toggler{cursor:pointer}.ngx-json-treeview .clickable{cursor:pointer;text-decoration:none}.ngx-json-treeview .clickable:hover{text-decoration:underline}.ngx-json-treeview .punctuation{color:var(--ngx-json-punctuation, #000)}.ngx-json-treeview .segment-type-string>.segment-primitive{color:var(--ngx-json-string, #0451a5)}.ngx-json-treeview .segment-type-number>.segment-primitive{color:var(--ngx-json-number, #098658)}.ngx-json-treeview .segment-type-boolean>.segment-primitive{color:var(--ngx-json-boolean, #a31515)}.ngx-json-treeview .segment-type-date>.segment-primitive{color:var(--ngx-json-date, #05668d)}.ngx-json-treeview .segment-type-function>.segment-primitive{color:var(--ngx-json-function, #656e77)}.ngx-json-treeview .segment-type-null>.segment-primitive{color:var(--ngx-json-null, #fff)}.ngx-json-treeview .segment-type-undefined>.segment-primitive{color:var(--ngx-json-undefined, #fff)}.ngx-json-treeview .segment-primitive{margin:0;padding:0}.ngx-json-treeview .segment-type-null>.segment-primitive{background-color:var(--ngx-json-null-bg, red)}\n"], dependencies: [{ kind: "component", type: NgxJsonTreeviewComponent, selector: "ngx-json-treeview", inputs: ["json", "expanded", "depth", "enableClickableValues", "isClickableValue", "_parent", "_currentDepth"], outputs: ["onValueClick"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.1", ngImport: i0, type: NgxJsonTreeviewComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-json-treeview', imports: [], template: "<section class=\"ngx-json-treeview\">\n @if (segments().length === 0) {\n <div [class]=\"primativeSegmentClass()\">\n <span class=\"segment-primitive\">\n {{ asString() }}\n </span>\n </div>\n } @else {\n @if (_currentDepth() === 0) {\n <span class=\"punctuation\">{{ openingBrace() }}</span>\n }\n @for (\n segment of segments();\n track segment;\n let i = $index;\n let len = $count\n ) {\n @let needsComma = i < len - 1;\n @let expandable = isExpandable(segment);\n @let empty = isEmpty(segment);\n @let openingBrace = openingBraceForSegment(segment);\n @let closingBrace = closingBraceForSegment(segment);\n @let clickableValue = isClickable(segment);\n <div [class]=\"'segment segment-type-' + segment.type\">\n <div class=\"segment-main\">\n <span\n [tabindex]=\"expandable ? 0 : -1\"\n [class.expandable]=\"expandable\"\n [class.expanded]=\"segment.expanded\"\n (click)=\"toggle(segment)\"\n (keydown.enter)=\"toggle(segment)\">\n @if (expandable) {\n <div class=\"toggler\"></div>\n }\n @if (isArrayElement()) {\n <span class=\"segment-label\">{{ segment.key }}</span>\n } @else {\n <span class=\"segment-key\">{{ `\"${segment.key}\"` }}</span>\n }\n </span>\n <span\n [class.segment-label]=\"isArrayElement()\"\n [class.punctuation]=\"!isArrayElement()\"\n >:\n </span>\n @if (empty) {\n <span class=\"punctuation\"\n >{{ openingBrace }}{{ closingBrace\n }}{{ needsComma ? ',' : '' }}</span\n >\n } @else if (!expandable || !segment.expanded) {\n <span\n [tabindex]=\"clickableValue ? 0 : -1\"\n [class.segment-label]=\"expandable\"\n [class.segment-value]=\"!expandable\"\n [class.clickable]=\"clickableValue\"\n (click)=\"onValueClickHandler(segment)\"\n (keydown.enter)=\"onValueClickHandler(segment)\"\n >{{ segment.description }}</span\n >\n <span class=\"punctuation\">{{ needsComma ? ',' : '' }}</span>\n } @else {\n <span class=\"punctuation\">\n {{ openingBrace }}\n </span>\n }\n </div>\n @if (expandable && segment.expanded) {\n <div class=\"children\">\n <ngx-json-treeview\n [json]=\"segment.value\"\n [expanded]=\"expanded()\"\n [depth]=\"depth()\"\n [isClickableValue]=\"isClickableValue()\"\n [enableClickableValues]=\"enableClickableValues()\"\n [_parent]=\"segment\"\n [_currentDepth]=\"_currentDepth() + 1\"\n (onValueClick)=\"onValueClickHandler($event)\" />\n <span class=\"punctuation\">\n {{ closingBrace }}{{ needsComma ? ',' : '' }}\n </span>\n </div>\n }\n </div>\n }\n @if (_currentDepth() === 0) {\n <span class=\"punctuation\">{{ closingBrace() }}</span>\n }\n }\n</section>\n", styles: ["@charset \"UTF-8\";.ngx-json-treeview{font-family:var(--ngx-json-font-family, monospace);font-size:var(--ngx-json-font-size, 1em);overflow:hidden;position:relative}.ngx-json-treeview .segment{margin:1px 1px 1px 12px;padding:2px}.ngx-json-treeview .segment .segment-main{word-wrap:break-word;margin:1px 1px 1px 12px}.ngx-json-treeview .segment .segment-main .toggler{color:var(--ngx-json-toggler, #787878);font-size:.8em;line-height:1.2em;margin-left:-14px;margin-top:3px;position:absolute;vertical-align:middle}.ngx-json-treeview .segment .segment-main .toggler:after{content:\"\\25b6\";display:inline-block;transition:transform .1s ease-in}.ngx-json-treeview .segment .segment-main .segment-key{color:var(--ngx-json-key, #a31515)}.ngx-json-treeview .segment .segment-main .segment-label{color:var(--ngx-json-label, #656e77);font-style:italic}.ngx-json-treeview .segment .segment-main .segment-value{color:var(--ngx-json-value, #000)}.ngx-json-treeview .segment .children{margin-left:12px}.ngx-json-treeview .segment-type-string>.segment-main>.segment-value{color:var(--ngx-json-string, #0451a5)}.ngx-json-treeview .segment-type-number>.segment-main>.segment-value{color:var(--ngx-json-number, #098658)}.ngx-json-treeview .segment-type-boolean>.segment-main>.segment-value{color:var(--ngx-json-boolean, #a31515)}.ngx-json-treeview .segment-type-date>.segment-main>.segment-value{color:var(--ngx-json-date, #05668d)}.ngx-json-treeview .segment-type-function>.segment-main>.segment-value{color:var(--ngx-json-function, #656e77)}.ngx-json-treeview .segment-type-null>.segment-main>.segment-value{color:var(--ngx-json-null, #fff)}.ngx-json-treeview .segment-type-undefined>.segment-main>.segment-value{color:var(--ngx-json-undefined, #fff)}.ngx-json-treeview .segment-type-null>.segment-main>.segment-value{background-color:var(--ngx-json-null-bg, red)}.ngx-json-treeview .segment-type-undefined>.segment-main>.segment-key{color:var(--ngx-json-undefined-key, #a31515)}.ngx-json-treeview .segment-type-undefined>.segment-main>.segment-value{background-color:var(--ngx-json-undefined-bg, #656e77)}.ngx-json-treeview .segment-type-object>.segment-main,.ngx-json-treeview .segment-type-array>.segment-main{white-space:nowrap}.ngx-json-treeview .expanded>.toggler:after{transform:rotate(90deg)}.ngx-json-treeview .expandable,.ngx-json-treeview .expandable>.toggler{cursor:pointer}.ngx-json-treeview .clickable{cursor:pointer;text-decoration:none}.ngx-json-treeview .clickable:hover{text-decoration:underline}.ngx-json-treeview .punctuation{color:var(--ngx-json-punctuation, #000)}.ngx-json-treeview .segment-type-string>.segment-primitive{color:var(--ngx-json-string, #0451a5)}.ngx-json-treeview .segment-type-number>.segment-primitive{color:var(--ngx-json-number, #098658)}.ngx-json-treeview .segment-type-boolean>.segment-primitive{color:var(--ngx-json-boolean, #a31515)}.ngx-json-treeview .segment-type-date>.segment-primitive{color:var(--ngx-json-date, #05668d)}.ngx-json-treeview .segment-type-function>.segment-primitive{color:var(--ngx-json-function, #656e77)}.ngx-json-treeview .segment-type-null>.segment-primitive{color:var(--ngx-json-null, #fff)}.ngx-json-treeview .segment-type-undefined>.segment-primitive{color:var(--ngx-json-undefined, #fff)}.ngx-json-treeview .segment-primitive{margin:0;padding:0}.ngx-json-treeview .segment-type-null>.segment-primitive{background-color:var(--ngx-json-null-bg, red)}\n"] }] }] }); /* * Public API Surface of ngx-json-treeview */ /** * Generated bundle index. Do not edit. */ export { NgxJsonTreeviewComponent }; //# sourceMappingURL=ngx-json-treeview.mjs.map