|
<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> |
|
|