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:
@@ -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>
|
||||
Reference in New Issue
Block a user