Browse Source

feat(json-view): add inline collapsed previews

dev
hechang27-sprt 3 months ago
parent
commit
0be305f514
  1. 18
      examples/view/base/json-view.vue
  2. 40
      packages/base/data/json-view/flattenJson.ts
  3. 299
      packages/base/data/json-view/inlinePreview.ts
  4. 78
      packages/base/data/json-view/json-view.vue
  5. 8
      packages/base/data/json-view/types.ts

18
examples/view/base/json-view.vue

@ -25,6 +25,14 @@
<span>Dynamic height</span> <span>Dynamic height</span>
<input v-model="options.dynamicHeight" type="checkbox" /> <input v-model="options.dynamicHeight" type="checkbox" />
</label> </label>
<label class="demo-option">
<span>Inline collapsed preview</span>
<input v-model="options.inline" type="checkbox" />
</label>
<label class="demo-option">
<span>Collapse fully inline node</span>
<input v-model="options.collapseFullyInlineNode" type="checkbox" />
</label>
<label class="demo-option"> <label class="demo-option">
<span>Show line guides</span> <span>Show line guides</span>
<input v-model="options.showLine" type="checkbox" /> <input v-model="options.showLine" type="checkbox" />
@ -68,6 +76,10 @@
<span>Collapse length over</span> <span>Collapse length over</span>
<input v-model.number="options.collapsedNodeLength" min="1" max="40" type="number" /> <input v-model.number="options.collapsedNodeLength" min="1" max="40" type="number" />
</label> </label>
<label class="demo-option">
<span>Max inline width</span>
<input v-model.number="options.maxInlineDiplayWidth" min="120" max="1200" step="20" type="number" />
</label>
</div> </div>
</div> </div>
</section> </section>
@ -84,6 +96,8 @@
:data="parsedData" :data="parsedData"
:virtual="options.virtual" :virtual="options.virtual"
:dynamic-height="options.dynamicHeight" :dynamic-height="options.dynamicHeight"
:inline="options.inline"
:collapse-fully-inline-node="options.collapseFullyInlineNode"
:show-line="options.showLine" :show-line="options.showLine"
:show-line-number="options.showLineNumber" :show-line-number="options.showLineNumber"
:show-icon="options.showIcon" :show-icon="options.showIcon"
@ -92,6 +106,7 @@
:indent="options.indent" :indent="options.indent"
:height="options.height" :height="options.height"
:item-height="options.itemHeight" :item-height="options.itemHeight"
:max-inline-diplay-width="options.maxInlineDiplayWidth"
:deep="normalizeNumber(options.deep)" :deep="normalizeNumber(options.deep)"
:collapsed-node-length="normalizeNumber(options.collapsedNodeLength)" :collapsed-node-length="normalizeNumber(options.collapsedNodeLength)"
:menu-items="menuItems" :menu-items="menuItems"
@ -208,6 +223,8 @@ const parseError = ref("");
const options = reactive({ const options = reactive({
virtual: true, virtual: true,
dynamicHeight: true, dynamicHeight: true,
inline: true,
collapseFullyInlineNode: false,
showLine: true, showLine: true,
showLineNumber: false, showLineNumber: false,
showIcon: true, showIcon: true,
@ -216,6 +233,7 @@ const options = reactive({
indent: 2, indent: 2,
height: 360, height: 360,
itemHeight: 20, itemHeight: 20,
maxInlineDiplayWidth: 600,
deep: 4, deep: 4,
collapsedNodeLength: 10, collapsedNodeLength: 10,
}); });

40
packages/base/data/json-view/flattenJson.ts

@ -1,4 +1,5 @@
import type { BuildVisibleJsonRowsOptions, JsonViewNode, JsonViewNodeType } from "./types"; import type { BuildVisibleJsonRowsOptions, JsonViewNode, JsonViewNodeType } from "./types";
import { buildInlinePreview } from "./inlinePreview";
import { import {
formatCircularReference, formatCircularReference,
isCircularReferenceMarker, isCircularReferenceMarker,
@ -91,12 +92,8 @@ function isCollapsedByDefault(level: number, length: number, options: BuildVisib
return deepCollapsed || lengthCollapsed; return deepCollapsed || lengthCollapsed;
} }
function isExpanded(path: string, level: number, length: number, options: BuildVisibleJsonRowsOptions) { function supportsInlinePreview(kind: JsonContainerKind) {
const explicit = options.expandedState.get(path); return kind === "object" || kind === "array";
if (explicit !== undefined) {
return explicit;
}
return !isCollapsedByDefault(level, length, options);
} }
function formatKeyDisplay(value: unknown) { function formatKeyDisplay(value: unknown) {
@ -381,12 +378,39 @@ function appendContainerRows(
ancestors: WeakMap<object, string> ancestors: WeakMap<object, string>
) { ) {
const length = getContainerLength(value, kind); const length = getContainerLength(value, kind);
const expanded = isExpanded(context.path, context.level, length, options); let collapsedByDefault = isCollapsedByDefault(context.level, length, options);
let inlinePreviewResult: ReturnType<typeof buildInlinePreview> | 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<object, true>()); const totalLineCount = 2 + countContainerChildLines(kind, value, new WeakMap<object, true>());
if (!expanded) { if (!expanded) {
const collapsedContent =
inlinePreviewResult?.text ?? (options.inline && supportsInlinePreview(kind)
? buildInlinePreview(value, {
maxWidth: options.maxInlineDiplayWidth ?? 600,
showDoubleQuotes,
showKeyValueSpace,
}).text
: getContainerContent(kind, "collapsed"));
rows.push( rows.push(
createNode(counter, context, getContainerNodeType(kind, "collapsed"), getContainerContent(kind, "collapsed"), value, { createNode(counter, context, getContainerNodeType(kind, "collapsed"), collapsedContent, value, {
length, length,
hasToggle: true, hasToggle: true,
isExpanded: false, isExpanded: false,

299
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<string, unknown>;
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<string, unknown> {
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<object, true>
): 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<object, true>
): 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<object, true>()
): 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);
}
}

78
packages/base/data/json-view/json-view.vue

@ -20,68 +20,7 @@
@pointerleave="clearMenuHoverSuppression" @pointerleave="clearMenuHoverSuppression"
@scroll="handleScroll" @scroll="handleScroll"
> >
<div v-if="props.virtual" class="json-view__spacer" :style="{ height: `${virtualTotalHeight}px` }"> <div class="json-view__list" :style="props.virtual ? { height: `${virtualTotalHeight}px` } : undefined">
<div
v-for="row in renderedRows"
:key="row.node.id"
class="json-view__row"
:style="getRowStyle(row)"
role="treeitem"
:aria-level="row.node.level + 1"
:aria-setsize="row.node.setSize"
:aria-posinset="row.node.posInSet"
:aria-expanded="row.node.hasToggle ? row.node.isExpanded : undefined"
tabindex="-1"
@click="emit('nodeClick', row.node)"
@mouseenter="emit('nodeMouseover', row.node)"
>
<div v-if="props.showLineNumber" class="json-view__line-number" :style="lineNumberStyle">
{{ getLineNumber(row.node, row.index) }}
</div>
<div class="json-view__line" :style="getLineStyle(row.node)">
<button
v-if="props.showIcon && row.node.hasToggle"
class="json-view__toggle"
type="button"
:class="{ 'is-expanded': row.node.isExpanded }"
:aria-label="row.node.isExpanded ? 'Collapse node' : 'Expand node'"
@click.stop="toggleNode(row.node, 'icon')"
></button>
<span v-if="hasKey(row.node)" class="json-view__key">
<KeyRenderer :node="row.node" />
<span class="json-view__colon">{{ props.showKeyValueSpace ? ": " : ":" }}</span>
</span>
<span
class="json-view__value"
:class="getValueClass(row.node)"
@click.stop="handleBracketClick(row.node)"
>
<ValueRenderer :node="row.node" />
</span>
<span v-if="props.showLength && shouldShowLength(row.node)" class="json-view__length">
({{ row.node.length }})
</span>
<ActionsRenderer v-if="hasActionsRenderer" :node="row.node" />
<div v-if="showsMenu" class="json-view__menu-shell">
<button
class="json-view__menu-trigger"
type="button"
aria-haspopup="menu"
:aria-expanded="isMenuOpen(row.node)"
aria-label="Open node menu"
@click.stop="openMenu(row.node, $event)"
></button>
</div>
</div>
</div>
</div>
<div v-else class="json-view__list">
<div <div
v-for="row in renderedRows" v-for="row in renderedRows"
:key="row.node.id" :key="row.node.id"
@ -180,6 +119,7 @@ import type { DropdownInstance } from "element-plus";
import { measureShrinkWrapWidth, measureTextHeight } from "../list-table-v2/measureText"; import { measureShrinkWrapWidth, measureTextHeight } from "../list-table-v2/measureText";
import { useVirtualRows } from "../list-table-v2/useVirtualRows"; import { useVirtualRows } from "../list-table-v2/useVirtualRows";
import { buildVisibleJsonRows } from "./flattenJson"; import { buildVisibleJsonRows } from "./flattenJson";
import { JSON_VIEW_DEFAULT_FONT } from "./inlinePreview";
import { import {
formatCircularReference, formatCircularReference,
isCircularReferenceMarker, isCircularReferenceMarker,
@ -187,7 +127,7 @@ import {
} from "./normalizeValue"; } from "./normalizeValue";
import type { JsonViewMenuActionContext, JsonViewMenuItem, JsonViewNode, JsonViewProps } from "./types"; import type { JsonViewMenuActionContext, JsonViewMenuItem, JsonViewNode, JsonViewProps } from "./types";
const DEFAULT_FONT = "13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace"; const DEFAULT_FONT = JSON_VIEW_DEFAULT_FONT;
const DEFAULT_LINE_HEIGHT = 20; const DEFAULT_LINE_HEIGHT = 20;
const ROW_VERTICAL_PADDING = 0; const ROW_VERTICAL_PADDING = 0;
const LINE_NUMBER_PADDING_START = 6; const LINE_NUMBER_PADDING_START = 6;
@ -216,6 +156,9 @@ const props = withDefaults(defineProps<JsonViewProps>(), {
rootPath: "root", rootPath: "root",
indent: 2, indent: 2,
collapsedNodeLength: undefined, collapsedNodeLength: undefined,
inline: false,
maxInlineDiplayWidth: 600,
collapseFullyInlineNode: false,
deep: undefined, deep: undefined,
showLength: false, showLength: false,
showLine: true, showLine: true,
@ -260,6 +203,11 @@ const visibleNodes = computed(() =>
rootPath: props.rootPath, rootPath: props.rootPath,
deep: props.deep, deep: props.deep,
collapsedNodeLength: props.collapsedNodeLength, collapsedNodeLength: props.collapsedNodeLength,
inline: props.inline,
maxInlineDiplayWidth: props.maxInlineDiplayWidth,
collapseFullyInlineNode: props.collapseFullyInlineNode,
showDoubleQuotes: props.showDoubleQuotes,
showKeyValueSpace: props.showKeyValueSpace,
expandedState: expandedState.value, expandedState: expandedState.value,
}) })
); );
@ -874,10 +822,6 @@ onUnmounted(() => {
overflow-x: hidden; overflow-x: hidden;
} }
.json-view__spacer {
position: relative;
}
.json-view__list { .json-view__list {
position: relative; position: relative;
} }

8
packages/base/data/json-view/types.ts

@ -67,6 +67,9 @@ export interface JsonViewProps {
rootPath?: string; rootPath?: string;
indent?: number; indent?: number;
collapsedNodeLength?: number; collapsedNodeLength?: number;
inline?: boolean;
maxInlineDiplayWidth?: number;
collapseFullyInlineNode?: boolean;
deep?: number; deep?: number;
showLength?: boolean; showLength?: boolean;
showLine?: boolean; showLine?: boolean;
@ -91,5 +94,10 @@ export interface BuildVisibleJsonRowsOptions {
rootPath: string; rootPath: string;
deep?: number; deep?: number;
collapsedNodeLength?: number; collapsedNodeLength?: number;
inline?: boolean;
maxInlineDiplayWidth?: number;
collapseFullyInlineNode?: boolean;
showDoubleQuotes?: boolean;
showKeyValueSpace?: boolean;
expandedState: ReadonlyMap<string, boolean>; expandedState: ReadonlyMap<string, boolean>;
} }

Loading…
Cancel
Save