From bff1014a1a376cbb89f210a351087fe92d70d590 Mon Sep 17 00:00:00 2001 From: Roccoon Date: Fri, 22 May 2026 19:43:36 +0800 Subject: [PATCH 1/5] Add Markdown preview and long-text expand option - ValuePopup: render long strings as Markdown by default, with a toggle to fall back to the plain
 view
- Add a dependency-free, sanitized Markdown renderer (markdown.tsx)
- JsonTree: add a toolbar toggle to show long strings in full
  instead of truncating them to a single ellipsized line
- Bump to v0.5.0

Co-Authored-By: Claude Opus 4.7 
---
 package.json                  |   2 +-
 src/App.css                   | 117 +++++++++++++
 src/App.tsx                   |  10 ++
 src/components/JsonNode.tsx   |   4 +-
 src/components/JsonTree.tsx   |   4 +
 src/components/ValuePopup.tsx |  27 ++-
 src/utils/markdown.tsx        | 318 ++++++++++++++++++++++++++++++++++
 7 files changed, 476 insertions(+), 6 deletions(-)
 create mode 100644 src/utils/markdown.tsx

diff --git a/package.json b/package.json
index 593117b..afb14a1 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "json-tool",
   "private": true,
-  "version": "0.4.0",
+  "version": "0.5.0",
   "type": "module",
   "scripts": {
     "dev": "vite",
diff --git a/src/App.css b/src/App.css
index a9d3161..5f0077d 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1071,6 +1071,12 @@ body {
   color: var(--ink-faint);
   cursor: not-allowed;
 }
+.tbtn.is-active {
+  color: var(--accent);
+}
+.tbtn.is-active::after {
+  transform: scaleX(1);
+}
 
 /* ─────────────────────────── tree search ─────────────────────────── */
 
@@ -1481,6 +1487,117 @@ body {
   background: var(--code-bg-2);
 }
 
+.modal-check.is-disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+}
+.modal-check.is-disabled input {
+  cursor: not-allowed;
+}
+
+/* ─────────────────────── markdown preview ─────────────────────── */
+.modal-body-md {
+  background: var(--paper);
+  color: var(--ink);
+  font-family: var(--serif);
+  font-size: 15px;
+  line-height: 1.75;
+  white-space: normal;
+  background-image: none;
+}
+.modal-body-md > :first-child {
+  margin-top: 0;
+}
+.modal-body-md > :last-child {
+  margin-bottom: 0;
+}
+.modal-body-md .md-h {
+  font-family: var(--sans);
+  font-weight: 600;
+  line-height: 1.3;
+  margin: 1.2em 0 0.5em;
+  color: var(--ink);
+  letter-spacing: -0.005em;
+}
+.modal-body-md .md-h1 {
+  font-size: 26px;
+  border-bottom: 1px solid var(--rule);
+  padding-bottom: 0.25em;
+}
+.modal-body-md .md-h2 {
+  font-size: 22px;
+  border-bottom: 1px solid var(--rule);
+  padding-bottom: 0.2em;
+}
+.modal-body-md .md-h3 { font-size: 18px; }
+.modal-body-md .md-h4 { font-size: 16px; }
+.modal-body-md .md-h5 { font-size: 14px; color: var(--ink-muted); }
+.modal-body-md .md-h6 {
+  font-size: 13px;
+  color: var(--ink-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+}
+.modal-body-md .md-p {
+  margin: 0.6em 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+.modal-body-md .md-list {
+  margin: 0.5em 0;
+  padding-left: 1.6em;
+}
+.modal-body-md .md-list li {
+  margin: 0.2em 0;
+}
+.modal-body-md .md-quote {
+  margin: 0.8em 0;
+  padding: 0.2em 0.9em;
+  border-left: 3px solid var(--accent);
+  background: var(--paper-2);
+  color: var(--ink-muted);
+  font-style: italic;
+}
+.modal-body-md .md-hr {
+  border: none;
+  border-top: 1px solid var(--rule);
+  margin: 1.2em 0;
+}
+.modal-body-md .md-code-inline {
+  font-family: var(--mono);
+  font-size: 0.88em;
+  padding: 1px 5px;
+  background: var(--code-bg);
+  color: var(--code-ink);
+  border-radius: 2px;
+}
+.modal-body-md .md-code-block {
+  margin: 0.8em 0;
+  padding: 12px 14px;
+  background: var(--code-bg);
+  color: var(--code-ink);
+  font-family: var(--mono);
+  font-size: 12.5px;
+  line-height: 1.6;
+  border-radius: 2px;
+  overflow: auto;
+  white-space: pre;
+}
+.modal-body-md .md-code-block code {
+  font-family: inherit;
+  background: transparent;
+  padding: 0;
+  color: inherit;
+}
+.modal-body-md .md-link {
+  color: var(--accent);
+  text-decoration: underline;
+  text-underline-offset: 2px;
+}
+.modal-body-md .md-link:hover {
+  text-decoration: none;
+}
+
 /* ─────────────────────────── animations ─────────────────────────── */
 
 @keyframes fadeUp {
diff --git a/src/App.tsx b/src/App.tsx
index dd075ce..220f2a9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -42,6 +42,7 @@ export default function App() {
   const [searchScope, setSearchScope] = useState('all');
   const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
   const [sortKeys, setSortKeys] = useState<'none' | 'asc' | 'desc'>('none');
+  const [expandLong, setExpandLong] = useState(false);
   const [shareLoading, setShareLoading] = useState(false);
   const [shareLoadError, setShareLoadError] = useState(null);
 
@@ -345,6 +346,14 @@ export default function App() {
                 ? 'Key A→Z'
                 : 'Key Z→A'}
             
+            
           
           
) : hasInput ? (
diff --git a/src/components/JsonNode.tsx b/src/components/JsonNode.tsx index 7d408e5..24ac4d1 100644 --- a/src/components/JsonNode.tsx +++ b/src/components/JsonNode.tsx @@ -19,6 +19,7 @@ export interface TreeApi { onOpenValue: (text: string, title?: string) => void; onDeletePath: (path: string) => void; sortKeys: SortKeysMode; + expandLong: boolean; search: { query: string; scope: SearchScope; @@ -271,7 +272,8 @@ function PrimitiveNode({ value, keyLabel, path, api }: JsonNodeProps) { } const looksLikeJson = isString && isLikelyJsonString(strValue); - const isLong = isString && strValue.length > LONG_VALUE_THRESHOLD; + const isLong = + isString && !api.expandLong && strValue.length > LONG_VALUE_THRESHOLD; const hasEscape = isString && /\\["'\\/bfnrt]|\\u[0-9a-fA-F]{4}/.test(strValue); const hasValueMatch = diff --git a/src/components/JsonTree.tsx b/src/components/JsonTree.tsx index c7c6c25..3330edc 100644 --- a/src/components/JsonTree.tsx +++ b/src/components/JsonTree.tsx @@ -13,6 +13,7 @@ interface JsonTreeProps { searchAncestors: Set; searchCurrentIndex: number; sortKeys: SortKeysMode; + expandLong: boolean; } export function JsonTree({ @@ -26,6 +27,7 @@ export function JsonTree({ searchAncestors, searchCurrentIndex, sortKeys, + expandLong, }: JsonTreeProps) { const [expandedOverride, setExpandedOverride] = useState< Map @@ -103,6 +105,7 @@ export function JsonTree({ onOpenValue, onDeletePath, sortKeys, + expandLong, search: { query: searchQuery, scope: searchScope, @@ -123,6 +126,7 @@ export function JsonTree({ currentPath, registerNode, sortKeys, + expandLong, ] ); diff --git a/src/components/ValuePopup.tsx b/src/components/ValuePopup.tsx index 9158b1e..6afc87d 100644 --- a/src/components/ValuePopup.tsx +++ b/src/components/ValuePopup.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { unescapeString } from '../utils/jsonUtils'; +import { renderMarkdown } from '../utils/markdown'; interface ValuePopupProps { text: string; @@ -10,9 +11,14 @@ interface ValuePopupProps { export function ValuePopup({ text, title, onClose }: ValuePopupProps) { const [wrap, setWrap] = useState(true); const [unesc, setUnesc] = useState(false); + const [md, setMd] = useState(true); const [copied, setCopied] = useState(false); const displayed = unesc ? unescapeString(text) : text; + const rendered = useMemo( + () => (md ? renderMarkdown(displayed) : null), + [md, displayed] + ); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -44,9 +50,18 @@ export function ValuePopup({ text, title, onClose }: ValuePopupProps) { {displayed.length.toLocaleString()} 字符 +
-
-          {displayed}
-        
+ {md ? ( +
{rendered}
+ ) : ( +
+            {displayed}
+          
+ )}
); diff --git a/src/utils/markdown.tsx b/src/utils/markdown.tsx new file mode 100644 index 0000000..eb6f27e --- /dev/null +++ b/src/utils/markdown.tsx @@ -0,0 +1,318 @@ +import type { ReactNode } from 'react'; + +type Block = + | { type: 'heading'; level: number; text: string } + | { type: 'paragraph'; text: string } + | { type: 'code'; lang: string; text: string } + | { type: 'quote'; lines: string[] } + | { type: 'ul'; items: string[] } + | { type: 'ol'; items: string[] } + | { type: 'hr' }; + +function isSafeUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return false; + if (/^(https?:|mailto:)/i.test(trimmed)) return true; + if (trimmed.startsWith('/') || trimmed.startsWith('#')) return true; + if (/^[a-zA-Z][\w./?#@:%+~=-]*$/.test(trimmed) && !trimmed.includes(':')) + return true; + return false; +} + +function parseBlocks(src: string): Block[] { + const lines = src.replace(/\r\n?/g, '\n').split('\n'); + const blocks: Block[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i++; + continue; + } + + const fence = line.match(/^```\s*([\w.+-]*)\s*$/); + if (fence) { + const lang = fence[1] ?? ''; + const codeLines: string[] = []; + i++; + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + codeLines.push(lines[i]); + i++; + } + if (i < lines.length) i++; + blocks.push({ type: 'code', lang, text: codeLines.join('\n') }); + continue; + } + + if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) { + blocks.push({ type: 'hr' }); + i++; + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/); + if (heading) { + blocks.push({ + type: 'heading', + level: heading[1].length, + text: heading[2], + }); + i++; + continue; + } + + if (/^\s*>/.test(line)) { + const quoteLines: string[] = []; + while (i < lines.length && /^\s*>/.test(lines[i])) { + quoteLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + blocks.push({ type: 'quote', lines: quoteLines }); + continue; + } + + if (/^\s*[-*+]\s+/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { + items.push(lines[i].replace(/^\s*[-*+]\s+/, '')); + i++; + } + blocks.push({ type: 'ul', items }); + continue; + } + + if (/^\s*\d+\.\s+/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { + items.push(lines[i].replace(/^\s*\d+\.\s+/, '')); + i++; + } + blocks.push({ type: 'ol', items }); + continue; + } + + const paraLines: string[] = [line]; + i++; + while ( + i < lines.length && + lines[i].trim() && + !/^#{1,6}\s+/.test(lines[i]) && + !/^```/.test(lines[i]) && + !/^\s*>/.test(lines[i]) && + !/^\s*[-*+]\s+/.test(lines[i]) && + !/^\s*\d+\.\s+/.test(lines[i]) && + !/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(lines[i]) + ) { + paraLines.push(lines[i]); + i++; + } + blocks.push({ type: 'paragraph', text: paraLines.join('\n') }); + } + + return blocks; +} + +interface InlineToken { + type: 'text' | 'code' | 'bold' | 'italic' | 'link' | 'break'; + text?: string; + url?: string; + children?: InlineToken[]; +} + +function parseInline(text: string): InlineToken[] { + const tokens: InlineToken[] = []; + let i = 0; + + const pushText = (s: string) => { + if (!s) return; + const parts = s.split(/( {2,}\n|\n)/); + for (const part of parts) { + if (!part) continue; + if (/^ {2,}\n$/.test(part) || part === '\n') { + tokens.push({ type: 'break' }); + } else { + tokens.push({ type: 'text', text: part }); + } + } + }; + + let buffer = ''; + while (i < text.length) { + const ch = text[i]; + + if (ch === '\\' && i + 1 < text.length) { + buffer += text[i + 1]; + i += 2; + continue; + } + + if (ch === '`') { + const end = text.indexOf('`', i + 1); + if (end !== -1) { + pushText(buffer); + buffer = ''; + tokens.push({ type: 'code', text: text.slice(i + 1, end) }); + i = end + 1; + continue; + } + } + + if (ch === '*' && text[i + 1] === '*') { + const end = text.indexOf('**', i + 2); + if (end !== -1) { + pushText(buffer); + buffer = ''; + tokens.push({ + type: 'bold', + children: parseInline(text.slice(i + 2, end)), + }); + i = end + 2; + continue; + } + } + + if ((ch === '*' || ch === '_') && text[i + 1] !== ch) { + const end = text.indexOf(ch, i + 1); + if (end !== -1 && end > i + 1) { + pushText(buffer); + buffer = ''; + tokens.push({ + type: 'italic', + children: parseInline(text.slice(i + 1, end)), + }); + i = end + 1; + continue; + } + } + + if (ch === '[') { + const close = text.indexOf(']', i + 1); + if (close !== -1 && text[close + 1] === '(') { + const urlEnd = text.indexOf(')', close + 2); + if (urlEnd !== -1) { + const label = text.slice(i + 1, close); + const url = text.slice(close + 2, urlEnd); + pushText(buffer); + buffer = ''; + tokens.push({ + type: 'link', + url, + children: parseInline(label), + }); + i = urlEnd + 1; + continue; + } + } + } + + buffer += ch; + i++; + } + + pushText(buffer); + return tokens; +} + +function renderInline(tokens: InlineToken[], keyPrefix: string): ReactNode[] { + return tokens.map((t, idx) => { + const key = `${keyPrefix}-${idx}`; + switch (t.type) { + case 'text': + return {t.text}; + case 'break': + return
; + case 'code': + return ( + + {t.text} + + ); + case 'bold': + return {renderInline(t.children ?? [], key)}; + case 'italic': + return {renderInline(t.children ?? [], key)}; + case 'link': { + const url = t.url ?? ''; + if (!isSafeUrl(url)) { + return {renderInline(t.children ?? [], key)}; + } + return ( + + {renderInline(t.children ?? [], key)} + + ); + } + default: + return null; + } + }); +} + +function renderBlock(block: Block, idx: number): ReactNode { + const key = `b-${idx}`; + switch (block.type) { + case 'heading': { + const inline = renderInline(parseInline(block.text), key); + const cls = `md-h md-h${block.level}`; + if (block.level === 1) return

{inline}

; + if (block.level === 2) return

{inline}

; + if (block.level === 3) return

{inline}

; + if (block.level === 4) return

{inline}

; + if (block.level === 5) return
{inline}
; + return
{inline}
; + } + case 'paragraph': + return ( +

+ {renderInline(parseInline(block.text), key)} +

+ ); + case 'code': + return ( +
+          {block.text}
+        
+ ); + case 'quote': + return ( +
+ {renderInline(parseInline(block.lines.join('\n')), key)} +
+ ); + case 'ul': + return ( +
    + {block.items.map((it, i) => ( +
  • + {renderInline(parseInline(it), `${key}-${i}`)} +
  • + ))} +
+ ); + case 'ol': + return ( +
    + {block.items.map((it, i) => ( +
  1. + {renderInline(parseInline(it), `${key}-${i}`)} +
  2. + ))} +
+ ); + case 'hr': + return
; + } +} + +export function renderMarkdown(src: string): ReactNode { + const blocks = parseBlocks(src); + return blocks.map((b, i) => renderBlock(b, i)); +} From 1db7263b6d7b051aa4bed23d424e0400e21390bd Mon Sep 17 00:00:00 2001 From: Roccoon Date: Fri, 22 May 2026 19:46:13 +0800 Subject: [PATCH 2/5] Allow esbuild build scripts for pnpm 10 corepack now resolves pnpm 10, which blocks dependency build scripts by default and exits non-zero on ERR_PNPM_IGNORED_BUILDS, breaking the Docker image build. Whitelist esbuild via pnpm.onlyBuiltDependencies. Co-Authored-By: Claude Opus 4.7 --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index afb14a1..87fd9cd 100644 --- a/package.json +++ b/package.json @@ -22,5 +22,8 @@ "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.6.3", "vite": "^5.4.11" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] } } From 7124540e70e449594e264eb7175a6a79d44bf5db Mon Sep 17 00:00:00 2001 From: Roccoon Date: Fri, 22 May 2026 19:48:33 +0800 Subject: [PATCH 3/5] Move pnpm onlyBuiltDependencies to pnpm-workspace.yaml Newer pnpm no longer reads the "pnpm" field in package.json, so the previous fix was ignored and the Docker build still failed on ERR_PNPM_IGNORED_BUILDS. Move the esbuild allowlist to its new home, pnpm-workspace.yaml. Co-Authored-By: Claude Opus 4.7 --- package.json | 3 --- pnpm-workspace.yaml | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/package.json b/package.json index 87fd9cd..afb14a1 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,5 @@ "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.6.3", "vite": "^5.4.11" - }, - "pnpm": { - "onlyBuiltDependencies": ["esbuild"] } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a19b804 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# pnpm settings (the new home for the former package.json "pnpm" field). +# esbuild needs its install script to fetch the platform binary; without +# this, pnpm 10 fails the CI install with ERR_PNPM_IGNORED_BUILDS. +onlyBuiltDependencies: + - esbuild From 30a2c8fc79e5466ec1b91e071d3a9216d6f369cb Mon Sep 17 00:00:00 2001 From: Roccoon Date: Fri, 22 May 2026 19:50:38 +0800 Subject: [PATCH 4/5] Copy pnpm-workspace.yaml before install in Docker build The image build copied only package.json and the lockfile before running pnpm install, so pnpm-workspace.yaml (which carries onlyBuiltDependencies) was missing and esbuild builds stayed ignored. Copy it alongside the manifest. Co-Authored-By: Claude Opus 4.7 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b28939a..9b01924 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:22-alpine AS build WORKDIR /app RUN corepack enable -COPY package.json pnpm-lock.yaml* ./ +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./ RUN pnpm install --frozen-lockfile || pnpm install COPY . . RUN pnpm build From 1d59bcc7dd26122f99b0e881ee4ea3edb4a2922f Mon Sep 17 00:00:00 2001 From: Roccoon Date: Fri, 22 May 2026 19:54:42 +0800 Subject: [PATCH 5/5] Pin pnpm version via packageManager field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker build ran corepack without a pinned pnpm version, so it floated to whatever pnpm corepack resolved by default — a build whose handling of onlyBuiltDependencies differed and kept failing on ERR_PNPM_IGNORED_BUILDS. Pin pnpm@10.28.0, which is verified to honour the esbuild allowlist on a clean store. Co-Authored-By: Claude Opus 4.7 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index afb14a1..aa1e77d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.5.0", "type": "module", + "packageManager": "pnpm@10.28.0", "scripts": { "dev": "vite", "build": "tsc -b && vite build",