feat: add JSON collapsible tree view, SVG rendered preview, and source toggle

- New JsonTreeView component with recursive collapsible nodes,
  auto-expand depth, and GitHub-themed dark mode colors
- JSON/JSONC/JSON5 files show tree view by default, toggle to
  Shiki-highlighted source
- SVG files show rendered preview (DOMPurify-sanitized) by default,
  toggle to Shiki-highlighted XML source
- SVG removed from IMAGE_EXTS to enable text-based preview
- YAML/TOML already covered by Shiki bundled languages
This commit is contained in:
Timothy Jaeryang Baek
2026-03-04 16:10:15 -06:00
parent c40f26946f
commit f4c38e6001
3 changed files with 198 additions and 5 deletions
+4 -2
View File
@@ -119,6 +119,8 @@
$: isMarkdown = MD_EXTS.has(getFileExt(selectedFile));
$: isCsv = CSV_EXTS.has(getFileExt(selectedFile));
$: isHtml = HTML_EXTS.has(getFileExt(selectedFile));
$: isJson = ['json', 'jsonc', 'jsonl', 'json5'].includes(getFileExt(selectedFile));
$: isSvg = getFileExt(selectedFile) === 'svg';
$: isCode = isCodeFile(selectedFile);
$: isOfficeFile = OFFICE_EXTS.has(getFileExt(selectedFile));
$: isTextFile =
@@ -182,7 +184,7 @@
}
// ── Helpers ──────────────────────────────────────────────────────────
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']);
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']);
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv']);
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus']);
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
@@ -634,7 +636,7 @@
</button>
</Tooltip>
{/if}
{#if (isMarkdown || isCsv || isHtml || isCode) && fileContent !== null && !editing}
{#if (isMarkdown || isCsv || isHtml || isCode || isJson || isSvg) && fileContent !== null && !editing}
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
@@ -7,6 +7,7 @@
import { isCodeFile, highlightCode } from '$lib/utils/codeHighlight';
import Spinner from '../../common/Spinner.svelte';
import PDFViewer from '../../common/PDFViewer.svelte';
import JsonTreeView from './JsonTreeView.svelte';
let pdfViewerRef: PDFViewer;
@@ -72,11 +73,14 @@
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
const CSV_EXTS = new Set(['csv', 'tsv']);
const HTML_EXTS = new Set(['html', 'htm']);
const JSON_EXTS = new Set(['json', 'jsonc', 'jsonl', 'json5']);
const getExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? '';
$: isMarkdown = MD_EXTS.has(getExt(selectedFile));
$: isCsv = CSV_EXTS.has(getExt(selectedFile));
$: isHtml = HTML_EXTS.has(getExt(selectedFile));
$: isJson = JSON_EXTS.has(getExt(selectedFile));
$: isSvg = getExt(selectedFile) === 'svg';
$: isCode = isCodeFile(selectedFile);
$: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ',';
$: renderedHtml =
@@ -131,10 +135,20 @@
let highlightedHtml: string | null = null;
let highlightingFile: string | null = null; // track which file we're highlighting
$: if (isCode && fileContent !== null && selectedFile) {
$: if ((isCode || isSvg) && fileContent !== null && selectedFile) {
const currentFile = selectedFile;
highlightingFile = currentFile;
highlightCode(fileContent, selectedFile)
const lang = isSvg ? 'xml' : undefined;
(lang
? import('shiki').then(({ codeToHtml }) =>
codeToHtml(fileContent!, {
lang: 'xml',
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: 'light'
})
)
: highlightCode(fileContent!, selectedFile!)
)
.then((html) => {
if (highlightingFile === currentFile) highlightedHtml = html;
})
@@ -145,6 +159,23 @@
highlightedHtml = null;
}
// ── JSON parsing ────────────────────────────────────────────────────
let parsedJson: unknown = undefined;
let jsonError: string | null = null;
$: if (isJson && fileContent !== null) {
try {
parsedJson = JSON.parse(fileContent);
jsonError = null;
} catch (e) {
parsedJson = undefined;
jsonError = e instanceof Error ? e.message : 'Invalid JSON';
}
} else {
parsedJson = undefined;
jsonError = null;
}
export let showRaw = false;
$: selectedFile, (showRaw = false); // reset to preview mode when switching files
@@ -321,7 +352,20 @@
</tbody>
</table>
</div>
{:else if isCode && highlightedHtml && !showRaw}
{:else if isJson && !showRaw && parsedJson !== undefined}
<div class="overflow-auto h-full">
<JsonTreeView data={parsedJson} />
</div>
{:else if isJson && !showRaw && jsonError}
<div class="p-3 text-xs">
<div class="text-red-500 mb-2">JSON parse error: {jsonError}</div>
<pre class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed">{fileContent}</pre>
</div>
{:else if isSvg && !showRaw && fileContent}
<div class="svg-preview w-full h-full flex items-center justify-center overflow-auto p-3">
{@html DOMPurify.sanitize(fileContent, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['use'] })}
</div>
{:else if (isCode || isSvg) && highlightedHtml && !showRaw}
<div class="shiki-preview overflow-auto h-full text-xs">
{@html highlightedHtml}
</div>
@@ -0,0 +1,147 @@
<script lang="ts">
export let data: unknown;
export let key: string | null = null;
export let root = true;
export let expandDepth = 2;
let depth = 0;
export { depth };
$: expanded = depth < expandDepth;
const toggle = () => {
expanded = !expanded;
};
$: type = Array.isArray(data) ? 'array' : data === null ? 'null' : typeof data;
$: isExpandable = type === 'object' || type === 'array';
$: entries = isExpandable && data !== null ? Object.entries(data as Record<string, unknown>) : [];
$: bracket = type === 'array' ? ['[', ']'] : ['{', '}'];
$: preview =
type === 'array'
? `[${(data as unknown[]).length}]`
: type === 'object'
? `{${entries.length}}`
: '';
</script>
{#if isExpandable}
<div class="json-node" class:json-root={root}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span class="json-toggle" on:click={toggle}>
<span class="json-arrow" class:json-expanded={expanded}>▶</span>
{#if key !== null}<span class="json-key">{key}</span><span class="json-colon">: </span>{/if}
{#if !expanded}<span class="json-preview">{bracket[0]} {preview} {bracket[1]}</span>{/if}
{#if expanded}<span class="json-bracket">{bracket[0]}</span>{/if}
</span>
{#if expanded}
<div class="json-children">
{#each entries as [k, v], i}
<div class="json-entry">
<svelte:self data={v} key={type === 'array' ? null : k} root={false} depth={depth + 1} {expandDepth} />
{#if i < entries.length - 1}<span class="json-comma">,</span>{/if}
</div>
{/each}
</div>
<span class="json-bracket">{bracket[1]}</span>
{/if}
</div>
{:else}
<span class="json-leaf">
{#if key !== null}<span class="json-key">{key}</span><span class="json-colon">: </span>{/if}
{#if type === 'string'}
<span class="json-string">"{data}"</span>
{:else if type === 'number'}
<span class="json-number">{data}</span>
{:else if type === 'boolean'}
<span class="json-boolean">{data}</span>
{:else if type === 'null'}
<span class="json-null">null</span>
{:else}
<span>{String(data)}</span>
{/if}
</span>
{/if}
<style>
.json-root {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.75rem;
line-height: 1.6;
padding: 0.75rem 1rem;
}
.json-node {
/* keep structure visible */
}
.json-toggle {
cursor: pointer;
user-select: none;
}
.json-toggle:hover {
opacity: 0.7;
}
.json-arrow {
display: inline-block;
width: 1em;
font-size: 0.55em;
transition: transform 0.15s ease;
color: #9ca3af;
vertical-align: middle;
}
.json-expanded {
transform: rotate(90deg);
}
.json-children {
padding-left: 1.25em;
border-left: 1px solid rgba(128, 128, 128, 0.15);
margin-left: 0.35em;
}
.json-entry {
/* one entry per line */
}
.json-key {
color: #0550ae;
}
:global(.dark) .json-key {
color: #79c0ff;
}
.json-colon {
color: #6b7280;
}
.json-string {
color: #0a3069;
}
:global(.dark) .json-string {
color: #a5d6ff;
}
.json-number {
color: #0550ae;
}
:global(.dark) .json-number {
color: #79c0ff;
}
.json-boolean {
color: #cf222e;
}
:global(.dark) .json-boolean {
color: #ff7b72;
}
.json-null {
color: #6b7280;
font-style: italic;
}
.json-bracket {
color: #6b7280;
}
.json-comma {
color: #6b7280;
}
.json-preview {
color: #9ca3af;
font-size: 0.85em;
}
:global(.dark) .json-preview {
color: #6b7280;
}
</style>