forked from mengyxu/noob-components
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
300 lines
7.5 KiB
300 lines
7.5 KiB
|
3 months ago
|
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);
|
||
|
|
}
|
||
|
|
}
|