From 0be305f5142952bd8eeb2e994ede5cb57f863fc3 Mon Sep 17 00:00:00 2001 From: hechang27-sprt Date: Fri, 10 Apr 2026 14:22:25 +0800 Subject: [PATCH] feat(json-view): add inline collapsed previews --- examples/view/base/json-view.vue | 18 ++ packages/base/data/json-view/flattenJson.ts | 40 ++- packages/base/data/json-view/inlinePreview.ts | 299 ++++++++++++++++++ packages/base/data/json-view/json-view.vue | 78 +---- packages/base/data/json-view/types.ts | 8 + 5 files changed, 368 insertions(+), 75 deletions(-) create mode 100644 packages/base/data/json-view/inlinePreview.ts diff --git a/examples/view/base/json-view.vue b/examples/view/base/json-view.vue index bcb94d7..deb9f45 100644 --- a/examples/view/base/json-view.vue +++ b/examples/view/base/json-view.vue @@ -25,6 +25,14 @@ Dynamic height + + + @@ -84,6 +96,8 @@ :data="parsedData" :virtual="options.virtual" :dynamic-height="options.dynamicHeight" + :inline="options.inline" + :collapse-fully-inline-node="options.collapseFullyInlineNode" :show-line="options.showLine" :show-line-number="options.showLineNumber" :show-icon="options.showIcon" @@ -92,6 +106,7 @@ :indent="options.indent" :height="options.height" :item-height="options.itemHeight" + :max-inline-diplay-width="options.maxInlineDiplayWidth" :deep="normalizeNumber(options.deep)" :collapsed-node-length="normalizeNumber(options.collapsedNodeLength)" :menu-items="menuItems" @@ -208,6 +223,8 @@ const parseError = ref(""); const options = reactive({ virtual: true, dynamicHeight: true, + inline: true, + collapseFullyInlineNode: false, showLine: true, showLineNumber: false, showIcon: true, @@ -216,6 +233,7 @@ const options = reactive({ indent: 2, height: 360, itemHeight: 20, + maxInlineDiplayWidth: 600, deep: 4, collapsedNodeLength: 10, }); diff --git a/packages/base/data/json-view/flattenJson.ts b/packages/base/data/json-view/flattenJson.ts index 6b644e8..b57b0e5 100644 --- a/packages/base/data/json-view/flattenJson.ts +++ b/packages/base/data/json-view/flattenJson.ts @@ -1,4 +1,5 @@ import type { BuildVisibleJsonRowsOptions, JsonViewNode, JsonViewNodeType } from "./types"; +import { buildInlinePreview } from "./inlinePreview"; import { formatCircularReference, isCircularReferenceMarker, @@ -91,12 +92,8 @@ function isCollapsedByDefault(level: number, length: number, options: BuildVisib return deepCollapsed || lengthCollapsed; } -function isExpanded(path: string, level: number, length: number, options: BuildVisibleJsonRowsOptions) { - const explicit = options.expandedState.get(path); - if (explicit !== undefined) { - return explicit; - } - return !isCollapsedByDefault(level, length, options); +function supportsInlinePreview(kind: JsonContainerKind) { + return kind === "object" || kind === "array"; } function formatKeyDisplay(value: unknown) { @@ -381,12 +378,39 @@ function appendContainerRows( ancestors: WeakMap ) { const length = getContainerLength(value, kind); - const expanded = isExpanded(context.path, context.level, length, options); + let collapsedByDefault = isCollapsedByDefault(context.level, length, options); + let inlinePreviewResult: ReturnType | null = null; + const showDoubleQuotes = options.showDoubleQuotes ?? true; + const showKeyValueSpace = options.showKeyValueSpace ?? true; + + if (options.inline && supportsInlinePreview(kind) && (collapsedByDefault || options.collapseFullyInlineNode)) { + inlinePreviewResult = buildInlinePreview(value, { + maxWidth: options.maxInlineDiplayWidth ?? 600, + showDoubleQuotes, + showKeyValueSpace, + }); + + if (!collapsedByDefault && options.collapseFullyInlineNode && inlinePreviewResult.isComplete) { + collapsedByDefault = true; + } + } + + const explicitExpanded = options.expandedState.get(context.path); + const expanded = explicitExpanded !== undefined ? explicitExpanded : !collapsedByDefault; const totalLineCount = 2 + countContainerChildLines(kind, value, new WeakMap()); if (!expanded) { + const collapsedContent = + inlinePreviewResult?.text ?? (options.inline && supportsInlinePreview(kind) + ? buildInlinePreview(value, { + maxWidth: options.maxInlineDiplayWidth ?? 600, + showDoubleQuotes, + showKeyValueSpace, + }).text + : getContainerContent(kind, "collapsed")); + rows.push( - createNode(counter, context, getContainerNodeType(kind, "collapsed"), getContainerContent(kind, "collapsed"), value, { + createNode(counter, context, getContainerNodeType(kind, "collapsed"), collapsedContent, value, { length, hasToggle: true, isExpanded: false, diff --git a/packages/base/data/json-view/inlinePreview.ts b/packages/base/data/json-view/inlinePreview.ts new file mode 100644 index 0000000..666430a --- /dev/null +++ b/packages/base/data/json-view/inlinePreview.ts @@ -0,0 +1,299 @@ +import { measureShrinkWrapWidth } from "../list-table-v2/measureText"; +import { formatCircularReference, isCircularReferenceMarker, normalizeVueValue } from "./normalizeValue"; + +type PlainObject = Record; + +type InlineContainerKind = "object" | "array" | "map" | "set"; + +export const JSON_VIEW_DEFAULT_FONT = + "13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace"; + +const FULLY_COLLAPSED_STRING = '"..."'; +const FULLY_COLLAPSED_ARRAY = "[...]"; +const FULLY_COLLAPSED_OBJECT = "{...}"; +const MIN_KEPT_STRING_WIDTH = 150; + +export interface InlinePreviewOptions { + maxWidth: number; + showDoubleQuotes: boolean; + showKeyValueSpace: boolean; +} + +export interface InlinePreviewResult { + text: string; + isComplete: boolean; +} + +function measureWidth(text: string) { + return measureShrinkWrapWidth(text, JSON_VIEW_DEFAULT_FONT); +} + +function fits(text: string, maxWidth: number) { + return measureWidth(text) <= maxWidth; +} + +function isRecordLike(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set); +} + +function getContainerKind(value: unknown): InlineContainerKind | null { + if (Array.isArray(value)) { + return "array"; + } + if (value instanceof Map) { + return "map"; + } + if (value instanceof Set) { + return "set"; + } + if (isRecordLike(value)) { + return "object"; + } + return null; +} + +function getCollapsedContainerText(kind: InlineContainerKind) { + switch (kind) { + case "map": + return "Map {...}"; + case "set": + return "Set [...]"; + case "object": + return FULLY_COLLAPSED_OBJECT; + case "array": + default: + return FULLY_COLLAPSED_ARRAY; + } +} + +function formatPrimitiveText(value: unknown) { + const normalizedValue = normalizeVueValue(value); + if (isCircularReferenceMarker(normalizedValue)) { + return formatCircularReference(normalizedValue.path); + } + + value = normalizedValue; + + if (typeof value === "string") { + return JSON.stringify(value); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (typeof value === "bigint") { + return `${String(value)}n`; + } + if (value === null) { + return "null"; + } + if (value === undefined) { + return "undefined"; + } + if (typeof value === "symbol") { + return value.toString(); + } + if (typeof value === "function") { + return value.name ? `[Function ${value.name}]` : "[Function]"; + } + return String(value); +} + +function formatObjectKey(key: string, showDoubleQuotes: boolean) { + return showDoubleQuotes ? JSON.stringify(key) : key; +} + +function buildTruncatedString(value: string, charCount: number) { + if (charCount <= 0) { + return FULLY_COLLAPSED_STRING; + } + + const truncated = JSON.stringify(value.slice(0, charCount)); + return `${truncated.slice(0, -1)}..."`; +} + +function formatStringPreview(value: string, options: InlinePreviewOptions): InlinePreviewResult { + const full = JSON.stringify(value); + if (fits(full, options.maxWidth)) { + return { text: full, isComplete: true }; + } + + const preserveVisibleChars = measureWidth(full) <= MIN_KEPT_STRING_WIDTH; + let low = preserveVisibleChars ? 1 : 0; + let high = value.length; + let bestCount = -1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = buildTruncatedString(value, mid); + + if (fits(candidate, options.maxWidth)) { + bestCount = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + if (bestCount >= 0) { + return { + text: buildTruncatedString(value, bestCount), + isComplete: false, + }; + } + + return { + text: FULLY_COLLAPSED_STRING, + isComplete: false, + }; +} + +function formatArrayPreview( + value: unknown[], + options: InlinePreviewOptions, + ancestors: WeakMap +): InlinePreviewResult { + if (value.length === 0) { + return { text: "[]", isComplete: true }; + } + + const parts: string[] = []; + let isComplete = true; + + for (let index = 0; index < value.length; index++) { + const prefix = parts.length > 0 ? `[${parts.join(", ")}, ` : "["; + const tail = index < value.length - 1 ? ", ...]" : "]"; + const budget = options.maxWidth - measureWidth(prefix) - measureWidth(tail); + + if (budget <= 0) { + return { + text: parts.length > 0 ? `[${parts.join(", ")}, ...]` : FULLY_COLLAPSED_ARRAY, + isComplete: false, + }; + } + + const item = buildInlinePreview(value[index], { ...options, maxWidth: budget }, ancestors); + const candidate = `${prefix}${item.text}${tail}`; + + if (!fits(candidate, options.maxWidth)) { + return { + text: parts.length > 0 ? `[${parts.join(", ")}, ...]` : FULLY_COLLAPSED_ARRAY, + isComplete: false, + }; + } + + parts.push(item.text); + if (!item.isComplete) { + isComplete = false; + } + } + + return { + text: `[${parts.join(", ")}]`, + isComplete, + }; +} + +function formatObjectPreview( + value: PlainObject, + options: InlinePreviewOptions, + ancestors: WeakMap +): InlinePreviewResult { + const entries = Object.entries(value); + if (entries.length === 0) { + return { text: "{}", isComplete: true }; + } + + const parts: string[] = []; + let isComplete = true; + + for (let index = 0; index < entries.length; index++) { + const [key, entryValue] = entries[index]; + const keyText = formatObjectKey(key, options.showDoubleQuotes); + const pairPrefix = `${keyText}${options.showKeyValueSpace ? ": " : ":"}`; + const prefix = parts.length > 0 ? `{${parts.join(", ")}, ${pairPrefix}` : `{${pairPrefix}`; + const tail = index < entries.length - 1 ? ", ...}" : "}"; + const budget = options.maxWidth - measureWidth(prefix) - measureWidth(tail); + + if (budget <= 0) { + return { + text: parts.length > 0 ? `{${parts.join(", ")}, ...}` : FULLY_COLLAPSED_OBJECT, + isComplete: false, + }; + } + + const item = buildInlinePreview(entryValue, { ...options, maxWidth: budget }, ancestors); + const candidate = `${prefix}${item.text}${tail}`; + + if (!fits(candidate, options.maxWidth)) { + return { + text: parts.length > 0 ? `{${parts.join(", ")}, ...}` : FULLY_COLLAPSED_OBJECT, + isComplete: false, + }; + } + + parts.push(`${pairPrefix}${item.text}`); + if (!item.isComplete) { + isComplete = false; + } + } + + return { + text: `{${parts.join(", ")}}`, + isComplete, + }; +} + +export function buildInlinePreview( + value: unknown, + options: InlinePreviewOptions, + ancestors = new WeakMap() +): InlinePreviewResult { + const normalizedValue = normalizeVueValue(value); + if (isCircularReferenceMarker(normalizedValue)) { + return { + text: formatCircularReference(normalizedValue.path), + isComplete: true, + }; + } + + value = normalizedValue; + + if (typeof value === "string") { + return formatStringPreview(value, options); + } + + const kind = getContainerKind(value); + if (!kind) { + return { + text: formatPrimitiveText(value), + isComplete: true, + }; + } + + if (kind === "map" || kind === "set") { + return { + text: getCollapsedContainerText(kind), + isComplete: false, + }; + } + + const reference = value as object; + if (ancestors.has(reference)) { + return { + text: formatCircularReference(), + isComplete: true, + }; + } + + ancestors.set(reference, true); + + try { + if (kind === "array") { + return formatArrayPreview(value as unknown[], options, ancestors); + } + + return formatObjectPreview(value as PlainObject, options, ancestors); + } finally { + ancestors.delete(reference); + } +} diff --git a/packages/base/data/json-view/json-view.vue b/packages/base/data/json-view/json-view.vue index a4ca4cf..1a0c95a 100644 --- a/packages/base/data/json-view/json-view.vue +++ b/packages/base/data/json-view/json-view.vue @@ -20,68 +20,7 @@ @pointerleave="clearMenuHoverSuppression" @scroll="handleScroll" > -
-
-
- {{ getLineNumber(row.node, row.index) }} -
-
- - - - - {{ props.showKeyValueSpace ? ": " : ":" }} - - - - - - - - ({{ row.node.length }}) - - - - -
- -
-
-
-
- -
+
(), { rootPath: "root", indent: 2, collapsedNodeLength: undefined, + inline: false, + maxInlineDiplayWidth: 600, + collapseFullyInlineNode: false, deep: undefined, showLength: false, showLine: true, @@ -260,6 +203,11 @@ const visibleNodes = computed(() => rootPath: props.rootPath, deep: props.deep, collapsedNodeLength: props.collapsedNodeLength, + inline: props.inline, + maxInlineDiplayWidth: props.maxInlineDiplayWidth, + collapseFullyInlineNode: props.collapseFullyInlineNode, + showDoubleQuotes: props.showDoubleQuotes, + showKeyValueSpace: props.showKeyValueSpace, expandedState: expandedState.value, }) ); @@ -874,10 +822,6 @@ onUnmounted(() => { overflow-x: hidden; } -.json-view__spacer { - position: relative; -} - .json-view__list { position: relative; } diff --git a/packages/base/data/json-view/types.ts b/packages/base/data/json-view/types.ts index 0213ae9..b52ed57 100644 --- a/packages/base/data/json-view/types.ts +++ b/packages/base/data/json-view/types.ts @@ -67,6 +67,9 @@ export interface JsonViewProps { rootPath?: string; indent?: number; collapsedNodeLength?: number; + inline?: boolean; + maxInlineDiplayWidth?: number; + collapseFullyInlineNode?: boolean; deep?: number; showLength?: boolean; showLine?: boolean; @@ -91,5 +94,10 @@ export interface BuildVisibleJsonRowsOptions { rootPath: string; deep?: number; collapsedNodeLength?: number; + inline?: boolean; + maxInlineDiplayWidth?: number; + collapseFullyInlineNode?: boolean; + showDoubleQuotes?: boolean; + showKeyValueSpace?: boolean; expandedState: ReadonlyMap; }