| <script lang="ts"> | |
| import { onMount, createEventDispatcher, tick, afterUpdate } from "svelte"; | |
| export let value: any; | |
| export let depth = 0; | |
| export let is_root = false; | |
| export let is_last_item = true; | |
| export let key: string | number | null = null; | |
| export let open = false; | |
| export let theme_mode: "system" | "light" | "dark" = "system"; | |
| export let show_indices = false; | |
| const dispatch = createEventDispatcher(); | |
| let root_element: HTMLElement; | |
| let collapsed = open ? false : depth >= 3; | |
| let child_nodes: any[] = []; | |
| function is_collapsible(val: any): boolean { | |
| return val !== null && (typeof val === "object" || Array.isArray(val)); | |
| } | |
| async function toggle_collapse(): Promise<void> { | |
| collapsed = !collapsed; | |
| await tick(); | |
| dispatch("toggle", { collapsed, depth }); | |
| } | |
| function get_collapsed_preview(val: any): string { | |
| if (Array.isArray(val)) return `Array(${val.length})`; | |
| if (typeof val === "object" && val !== null) | |
| return `Object(${Object.keys(val).length})`; | |
| return String(val); | |
| } | |
| $: if (is_collapsible(value)) { | |
| child_nodes = Object.entries(value); | |
| } else { | |
| child_nodes = []; | |
| } | |
| $: if (is_root && root_element) { | |
| updateLineNumbers(); | |
| } | |
| function updateLineNumbers(): void { | |
| const lines = root_element.querySelectorAll(".line"); | |
| lines.forEach((line, index) => { | |
| const line_number = line.querySelector(".line-number"); | |
| if (line_number) { | |
| line_number.setAttribute("data-pseudo-content", (index + 1).toString()); | |
| line_number?.setAttribute( | |
| "aria-roledescription", | |
| `Line number ${index + 1}` | |
| ); | |
| line_number?.setAttribute("title", `Line number ${index + 1}`); | |
| } | |
| }); | |
| } | |
| onMount(() => { | |
| if (is_root) { | |
| updateLineNumbers(); | |
| } | |
| }); | |
| afterUpdate(() => { | |
| if (is_root) { | |
| updateLineNumbers(); | |
| } | |
| }); | |
| </script> | |
| <div | |
| class="json-node" | |
| class:root={is_root} | |
| class:dark-mode={theme_mode === "dark"} | |
| bind:this={root_element} | |
| on:toggle | |
| style="--depth: {depth};" | |
| > | |
| <div class="line" class:collapsed> | |
| <span class="line-number"></span> | |
| <span class="content"> | |
| {#if is_collapsible(value)} | |
| <button | |
| data-pseudo-content={collapsed ? "▶" : "▼"} | |
| aria-label={collapsed ? "Expand" : "Collapse"} | |
| class="toggle" | |
| on:click={toggle_collapse} | |
| /> | |
| {/if} | |
| {#if key !== null} | |
| <span class="key">"{key}"</span><span class="punctuation colon" | |
| >: | |
| </span> | |
| {/if} | |
| {#if is_collapsible(value)} | |
| <span | |
| class="punctuation bracket" | |
| class:square-bracket={Array.isArray(value)} | |
| >{Array.isArray(value) ? "[" : "{"}</span | |
| > | |
| {#if collapsed} | |
| <button on:click={toggle_collapse} class="preview"> | |
| {get_collapsed_preview(value)} | |
| </button> | |
| <span | |
| class="punctuation bracket" | |
| class:square-bracket={Array.isArray(value)} | |
| >{Array.isArray(value) ? "]" : "}"}</span | |
| > | |
| {/if} | |
| {:else if typeof value === "string"} | |
| <span class="value string">"{value}"</span> | |
| {:else if typeof value === "number"} | |
| <span class="value number">{value}</span> | |
| {:else if typeof value === "boolean"} | |
| <span class="value bool">{value.toString()}</span> | |
| {:else if value === null} | |
| <span class="value null">null</span> | |
| {:else} | |
| <span>{value}</span> | |
| {/if} | |
| {#if !is_last_item && (!is_collapsible(value) || collapsed)} | |
| <span class="punctuation">,</span> | |
| {/if} | |
| </span> | |
| </div> | |
| {#if is_collapsible(value)} | |
| <div class="children" class:hidden={collapsed}> | |
| {#each child_nodes as [subKey, subVal], i} | |
| <svelte:self | |
| value={subVal} | |
| depth={depth + 1} | |
| is_last_item={i === child_nodes.length - 1} | |
| key={subKey} | |
| {open} | |
| {theme_mode} | |
| {show_indices} | |
| on:toggle | |
| /> | |
| {/each} | |
| <div class="line"> | |
| <span class="line-number"></span> | |
| <span class="content"> | |
| <span | |
| class="punctuation bracket" | |
| class:square-bracket={Array.isArray(value)} | |
| >{Array.isArray(value) ? "]" : "}"}</span | |
| > | |
| {#if !is_last_item}<span class="punctuation">,</span>{/if} | |
| </span> | |
| </div> | |
| </div> | |
| {/if} | |
| </div> | |
| <style> | |
| .json-node { | |
| font-family: var(--font-mono); | |
| --text-color: #d18770; | |
| --key-color: var(--text-color); | |
| --string-color: #ce9178; | |
| --number-color: #719fad; | |
| --bracket-color: #5d8585; | |
| --square-bracket-color: #be6069; | |
| --punctuation-color: #8fbcbb; | |
| --line-number-color: #6a737d; | |
| --separator-color: var(--line-number-color); | |
| } | |
| .json-node.dark-mode { | |
| --bracket-color: #7eb4b3; | |
| --number-color: #638d9a; | |
| } | |
| .json-node.root { | |
| position: relative; | |
| padding-left: var(--size-14); | |
| } | |
| .json-node.root::before { | |
| content: ""; | |
| position: absolute; | |
| top: 0; | |
| bottom: 0; | |
| left: var(--size-11); | |
| width: 1px; | |
| background-color: var(--separator-color); | |
| } | |
| .line { | |
| display: flex; | |
| align-items: flex-start; | |
| padding: 0; | |
| margin: 0; | |
| line-height: var(--line-md); | |
| } | |
| .line-number { | |
| position: absolute; | |
| left: 0; | |
| width: calc(var(--size-7)); | |
| text-align: right; | |
| color: var(--line-number-color); | |
| user-select: none; | |
| text-overflow: ellipsis; | |
| text-overflow: ellipsis; | |
| direction: rtl; | |
| overflow: hidden; | |
| } | |
| .content { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| padding-left: calc(var(--depth) * var(--size-2)); | |
| flex-wrap: wrap; | |
| } | |
| .children { | |
| padding-left: var(--size-4); | |
| } | |
| .children.hidden { | |
| display: none; | |
| } | |
| .key { | |
| color: var(--key-color); | |
| } | |
| .string { | |
| color: var(--string-color); | |
| } | |
| .number { | |
| color: var(--number-color); | |
| } | |
| .bool { | |
| color: var(--text-color); | |
| } | |
| .null { | |
| color: var(--text-color); | |
| } | |
| .value { | |
| margin-left: var(--spacing-md); | |
| } | |
| .punctuation { | |
| color: var(--punctuation-color); | |
| } | |
| .bracket { | |
| margin-left: var(--spacing-sm); | |
| color: var(--bracket-color); | |
| } | |
| .square-bracket { | |
| margin-left: var(--spacing-sm); | |
| color: var(--square-bracket-color); | |
| } | |
| .toggle, | |
| .preview { | |
| background: none; | |
| border: none; | |
| color: inherit; | |
| cursor: pointer; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| .toggle { | |
| user-select: none; | |
| margin-right: var(--spacing-md); | |
| } | |
| .preview { | |
| margin: 0 var(--spacing-sm) 0 var(--spacing-lg); | |
| } | |
| .preview:hover { | |
| text-decoration: underline; | |
| } | |
| :global([data-pseudo-content])::before { | |
| content: attr(data-pseudo-content); | |
| } | |
| </style> | |