From 4be6b90acba082dc2443676090893bc09dc22146 Mon Sep 17 00:00:00 2001 From: hechang27-sprt Date: Thu, 9 Apr 2026 17:03:17 +0800 Subject: [PATCH] WIP: json-viewer --- examples/App.vue | 1 + examples/config/language/en.ts | 2 + examples/config/language/zh.ts | 1 + examples/config/router.ts | 6 + examples/view/base/json-view.vue | 361 +++++++ packages/base/data/json-view/flattenJson.ts | 452 ++++++++ packages/base/data/json-view/index.ts | 17 + packages/base/data/json-view/json-view.vue | 1074 +++++++++++++++++++ packages/base/data/json-view/types.ts | 95 ++ packages/base/index.ts | 3 +- 10 files changed, 2011 insertions(+), 1 deletion(-) create mode 100644 examples/view/base/json-view.vue create mode 100644 packages/base/data/json-view/flattenJson.ts create mode 100644 packages/base/data/json-view/index.ts create mode 100644 packages/base/data/json-view/json-view.vue create mode 100644 packages/base/data/json-view/types.ts diff --git a/examples/App.vue b/examples/App.vue index 4421e65..0e7ae04 100644 --- a/examples/App.vue +++ b/examples/App.vue @@ -43,6 +43,7 @@ const menus = [ children: [ { i18n: "menu.table", path: "table", icon: "List" }, { i18n: "menu.tableV2", path: "table-v2", icon: "List" }, + { i18n: "menu.jsonView", path: "json-view", icon: "Document" }, { i18n: "menu.form", path: "form", icon: "Postcard" }, { i18n: "menu.pretextDemo", path: "pretext-demo", icon: "List" }, ], diff --git a/examples/config/language/en.ts b/examples/config/language/en.ts index 02dbc19..f50afad 100644 --- a/examples/config/language/en.ts +++ b/examples/config/language/en.ts @@ -37,6 +37,8 @@ export default class En extends Lang.En { base: 'General', table: 'Table', tableV2: 'Table(V2)', + jsonView: 'JSON View', + pretextDemo: 'Pretext Demo', form: 'Form', tool: 'Tool', terminal: 'Terminal', diff --git a/examples/config/language/zh.ts b/examples/config/language/zh.ts index d1fde66..d16ede5 100644 --- a/examples/config/language/zh.ts +++ b/examples/config/language/zh.ts @@ -30,6 +30,7 @@ export default class Zh extends Lang.Zh { base: "通用", table: "表格", tableV2: "表格(V2)", + jsonView: "JSON 视图", pretextDemo: "Pretext Demo", form: "表单", tool: "工具", diff --git a/examples/config/router.ts b/examples/config/router.ts index a1aed7e..fa6f756 100644 --- a/examples/config/router.ts +++ b/examples/config/router.ts @@ -3,6 +3,7 @@ import { Views, Common } from "noob-mengyxu"; import Home from "../view/home.vue"; import Table from "../view/base/table.vue"; import TableV2 from "../view/base/table-v2.vue"; +import JsonViewDemo from "../view/base/json-view.vue"; import Form from "../view/base/form.vue"; import Terminal from "../view/tool/terminal.vue"; import Color from "../view/tool/color.vue"; @@ -33,6 +34,11 @@ const routes: Array = [ name: "table-v2", component: TableV2, }, + { + path: "/json-view", + name: "json-view", + component: JsonViewDemo, + }, { path: "/form", name: "form", diff --git a/examples/view/base/json-view.vue b/examples/view/base/json-view.vue new file mode 100644 index 0000000..9cdd6f7 --- /dev/null +++ b/examples/view/base/json-view.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/packages/base/data/json-view/flattenJson.ts b/packages/base/data/json-view/flattenJson.ts new file mode 100644 index 0000000..8de94dd --- /dev/null +++ b/packages/base/data/json-view/flattenJson.ts @@ -0,0 +1,452 @@ +import type { BuildVisibleJsonRowsOptions, JsonViewNode, JsonViewNodeType } from "./types"; + +type PlainObject = Record; +type JsonContainerKind = "object" | "array" | "map" | "set"; +type JsonContainerValue = PlainObject | unknown[] | Map | Set; + +interface RowContext { + path: string; + level: number; + key?: string; + displayKey?: string; + keyValue?: unknown; + index?: number; + showComma: boolean; + posInSet: number; + setSize: number; +} + +interface LineCounter { + nextLineNumber: number; +} + +function isRecordLike(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set); +} + +function buildNodeId(path: string, type: JsonViewNodeType) { + return `${path}:${type}`; +} + +function buildObjectPath(parentPath: string, key: string) { + if (/^[A-Za-z_$][\w$]*$/.test(key)) { + return `${parentPath}.${key}`; + } + return `${parentPath}[${JSON.stringify(key)}]`; +} + +function buildArrayPath(parentPath: string, index: number) { + return `${parentPath}[${index}]`; +} + +function buildMapPath(parentPath: string, key: unknown, index: number) { + if (typeof key === "string") { + return `${parentPath}.get(${JSON.stringify(key)})`; + } + + if (typeof key === "number" || typeof key === "boolean" || typeof key === "bigint") { + return `${parentPath}.get(${String(key)})`; + } + + if (key === null) { + return `${parentPath}.get(null)`; + } + + if (key === undefined) { + return `${parentPath}.get(undefined)`; + } + + if (typeof key === "symbol") { + return `${parentPath}.get(${String(key)})`; + } + + return `${parentPath}.entries[${index}]`; +} + +function isCollapsedByDefault(level: number, length: number, options: BuildVisibleJsonRowsOptions) { + const deepCollapsed = typeof options.deep === "number" ? level + 1 > options.deep : false; + const lengthCollapsed = + typeof options.collapsedNodeLength === "number" ? length > options.collapsedNodeLength : false; + 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 formatKeyDisplay(value: unknown) { + 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]"; + } + if (Array.isArray(value)) { + return `Array(${value.length})`; + } + if (value instanceof Map) { + return `Map(${value.size})`; + } + if (value instanceof Set) { + return `Set(${value.size})`; + } + return "Object"; +} + +function formatPrimitiveDisplay(value: unknown) { + 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 getContainerKind(value: unknown): JsonContainerKind | 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 getContainerLength(value: JsonContainerValue, kind: JsonContainerKind) { + switch (kind) { + case "array": + return (value as unknown[]).length; + case "map": + return (value as Map).size; + case "set": + return (value as Set).size; + case "object": + default: + return Object.keys(value as PlainObject).length; + } +} + +function getContainerContent(kind: JsonContainerKind, state: "start" | "end" | "collapsed") { + switch (kind) { + case "map": + if (state === "start") return "Map {"; + if (state === "end") return "}"; + return "Map {...}"; + case "set": + if (state === "start") return "Set ["; + if (state === "end") return "]"; + return "Set [...]"; + case "object": + if (state === "start") return "{"; + if (state === "end") return "}"; + return "{...}"; + case "array": + default: + if (state === "start") return "["; + if (state === "end") return "]"; + return "[...]"; + } +} + +function getContainerNodeType(kind: JsonContainerKind, state: "start" | "end" | "collapsed"): JsonViewNodeType { + if (kind === "object" || kind === "map") { + if (state === "start") return "objectStart"; + if (state === "end") return "objectEnd"; + return "objectCollapsed"; + } + + if (state === "start") return "arrayStart"; + if (state === "end") return "arrayEnd"; + return "arrayCollapsed"; +} + +function countValueLines(value: unknown, ancestors = new WeakSet()): number { + const kind = getContainerKind(value); + if (!kind) { + return 1; + } + + const reference = value as object; + if (ancestors.has(reference)) { + return 1; + } + + ancestors.add(reference); + + const total = + kind === "array" + ? 2 + (value as unknown[]).reduce((sum, entry) => sum + countValueLines(entry, ancestors), 0) + : kind === "set" + ? 2 + Array.from(value as Set).reduce((sum, entry) => sum + countValueLines(entry, ancestors), 0) + : kind === "map" + ? 2 + + Array.from((value as Map).values()).reduce( + (sum, entry) => sum + countValueLines(entry, ancestors), + 0 + ) + : 2 + + Object.values(value as PlainObject).reduce((sum, entry) => sum + countValueLines(entry, ancestors), 0); + + ancestors.delete(reference); + return total; +} + +function countContainerChildLines(kind: JsonContainerKind, value: JsonContainerValue, ancestors: WeakSet) { + if (kind === "array") { + return (value as unknown[]).reduce((sum, entry) => sum + countValueLines(entry, ancestors), 0); + } + + if (kind === "set") { + return Array.from(value as Set).reduce((sum, entry) => sum + countValueLines(entry, ancestors), 0); + } + + if (kind === "map") { + return Array.from((value as Map).values()).reduce( + (sum, entry) => sum + countValueLines(entry, ancestors), + 0 + ); + } + + return Object.values(value as PlainObject).reduce((sum, entry) => sum + countValueLines(entry, ancestors), 0); +} + +function createNode( + counter: LineCounter, + context: RowContext, + type: JsonViewNodeType, + content: string, + value: unknown, + extras?: Partial +): JsonViewNode { + return { + id: buildNodeId(context.path, type), + path: context.path, + lineNumber: counter.nextLineNumber++, + level: context.level, + key: context.key, + displayKey: context.displayKey, + keyValue: context.keyValue, + index: context.index, + content, + showComma: context.showComma, + type, + value, + isLeaf: type === "content", + hasToggle: false, + isExpanded: false, + posInSet: context.posInSet, + setSize: context.setSize, + ...extras, + }; +} + +function appendValueRows( + value: unknown, + context: RowContext, + rows: JsonViewNode[], + options: BuildVisibleJsonRowsOptions, + counter: LineCounter, + ancestors: WeakSet +) { + const kind = getContainerKind(value); + if (!kind) { + rows.push(createNode(counter, context, "content", formatPrimitiveDisplay(value), value, { isLeaf: true })); + return; + } + + const reference = value as object; + if (ancestors.has(reference)) { + rows.push(createNode(counter, context, "content", "[Circular]", value, { isLeaf: true })); + return; + } + + ancestors.add(reference); + appendContainerRows(kind, value as JsonContainerValue, context, rows, options, counter, ancestors); + ancestors.delete(reference); +} + +function appendContainerRows( + kind: JsonContainerKind, + value: JsonContainerValue, + context: RowContext, + rows: JsonViewNode[], + options: BuildVisibleJsonRowsOptions, + counter: LineCounter, + ancestors: WeakSet +) { + const length = getContainerLength(value, kind); + const expanded = isExpanded(context.path, context.level, length, options); + const totalLineCount = 2 + countContainerChildLines(kind, value, ancestors); + + if (!expanded) { + rows.push( + createNode(counter, context, getContainerNodeType(kind, "collapsed"), getContainerContent(kind, "collapsed"), value, { + length, + hasToggle: true, + isExpanded: false, + isLeaf: false, + }) + ); + counter.nextLineNumber += totalLineCount - 1; + return; + } + + rows.push( + createNode(counter, context, getContainerNodeType(kind, "start"), getContainerContent(kind, "start"), value, { + length, + hasToggle: true, + isExpanded: true, + isLeaf: false, + showComma: false, + }) + ); + + if (kind === "object") { + Object.entries(value as PlainObject).forEach(([key, childValue], index) => { + appendValueRows( + childValue, + { + path: buildObjectPath(context.path, key), + level: context.level + 1, + key, + showComma: index < length - 1, + posInSet: index + 1, + setSize: length, + }, + rows, + options, + counter, + ancestors + ); + }); + } else if (kind === "array") { + (value as unknown[]).forEach((childValue, index) => { + appendValueRows( + childValue, + { + path: buildArrayPath(context.path, index), + level: context.level + 1, + index, + showComma: index < length - 1, + posInSet: index + 1, + setSize: length, + }, + rows, + options, + counter, + ancestors + ); + }); + } else if (kind === "set") { + Array.from(value as Set).forEach((childValue, index) => { + appendValueRows( + childValue, + { + path: buildArrayPath(context.path, index), + level: context.level + 1, + index, + showComma: index < length - 1, + posInSet: index + 1, + setSize: length, + }, + rows, + options, + counter, + ancestors + ); + }); + } else { + Array.from(value as Map).forEach(([keyValue, childValue], index) => { + appendValueRows( + childValue, + { + path: buildMapPath(context.path, keyValue, index), + level: context.level + 1, + displayKey: formatKeyDisplay(keyValue), + keyValue, + showComma: index < length - 1, + posInSet: index + 1, + setSize: length, + }, + rows, + options, + counter, + ancestors + ); + }); + } + + rows.push( + createNode(counter, context, getContainerNodeType(kind, "end"), getContainerContent(kind, "end"), value, { + length, + hasToggle: false, + isExpanded: expanded, + isLeaf: false, + key: undefined, + displayKey: undefined, + keyValue: undefined, + index: undefined, + }) + ); +} + +export function buildVisibleJsonRows(value: unknown, options: BuildVisibleJsonRowsOptions) { + const rows: JsonViewNode[] = []; + const counter: LineCounter = { nextLineNumber: 1 }; + appendValueRows( + value, + { + path: options.rootPath, + level: 0, + showComma: false, + posInSet: 1, + setSize: 1, + }, + rows, + options, + counter, + new WeakSet() + ); + return rows; +} diff --git a/packages/base/data/json-view/index.ts b/packages/base/data/json-view/index.ts new file mode 100644 index 0000000..9e1aaf7 --- /dev/null +++ b/packages/base/data/json-view/index.ts @@ -0,0 +1,17 @@ +import JsonView from "./json-view.vue"; + +export { JsonView }; +export default JsonView; + +export { buildVisibleJsonRows } from "./flattenJson"; +export type { + BuildVisibleJsonRowsOptions, + JsonViewNode, + JsonViewNodeActionsRenderer, + JsonViewNodeKeyRenderer, + JsonViewNodeRendererArgs, + JsonViewNodeType, + JsonViewNodeValueRenderer, + JsonViewProps, + JsonViewTheme, +} from "./types"; diff --git a/packages/base/data/json-view/json-view.vue b/packages/base/data/json-view/json-view.vue new file mode 100644 index 0000000..04f9f0a --- /dev/null +++ b/packages/base/data/json-view/json-view.vue @@ -0,0 +1,1074 @@ + + + + + diff --git a/packages/base/data/json-view/types.ts b/packages/base/data/json-view/types.ts new file mode 100644 index 0000000..0213ae9 --- /dev/null +++ b/packages/base/data/json-view/types.ts @@ -0,0 +1,95 @@ +import type { VNodeChild } from "vue"; + +export type JsonViewTheme = "light" | "dark"; + +export type JsonViewNodeType = + | "content" + | "objectStart" + | "objectEnd" + | "objectCollapsed" + | "arrayStart" + | "arrayEnd" + | "arrayCollapsed"; + +export interface JsonViewNode { + id: string; + path: string; + lineNumber: number; + level: number; + key?: string; + displayKey?: string; + keyValue?: unknown; + index?: number; + content: string; + length?: number; + showComma: boolean; + type: JsonViewNodeType; + value?: unknown; + isLeaf: boolean; + hasToggle: boolean; + isExpanded: boolean; + posInSet: number; + setSize: number; +} + +export interface JsonViewNodeRendererArgs { + node: JsonViewNode; + defaultKey?: string; + defaultValue?: string; + defaultActions?: null; +} + +export interface JsonViewMenuActionContext { + node: JsonViewNode; + path: string; + value: unknown; + copy: (text: string) => Promise; + closeMenu: () => void; +} + +export interface JsonViewMenuItem { + key: string; + label: string; + disabled?: boolean; + onSelect?: (context: JsonViewMenuActionContext) => void | Promise; +} + +export type JsonViewMenuItemsResolver = + | JsonViewMenuItem[] + | ((context: JsonViewMenuActionContext) => JsonViewMenuItem[]); + +export type JsonViewNodeKeyRenderer = (args: JsonViewNodeRendererArgs) => VNodeChild; +export type JsonViewNodeValueRenderer = (args: JsonViewNodeRendererArgs) => VNodeChild; +export type JsonViewNodeActionsRenderer = (args: JsonViewNodeRendererArgs) => VNodeChild; + +export interface JsonViewProps { + data?: unknown; + rootPath?: string; + indent?: number; + collapsedNodeLength?: number; + deep?: number; + showLength?: boolean; + showLine?: boolean; + showLineNumber?: boolean; + showIcon?: boolean; + showDoubleQuotes?: boolean; + showKeyValueSpace?: boolean; + virtual?: boolean; + height?: number; + itemHeight?: number; + dynamicHeight?: boolean; + collapsedOnClickBrackets?: boolean; + theme?: JsonViewTheme; + renderNodeKey?: JsonViewNodeKeyRenderer; + renderNodeValue?: JsonViewNodeValueRenderer; + renderNodeActions?: JsonViewNodeActionsRenderer; + showMenu?: boolean; + menuItems?: JsonViewMenuItemsResolver; +} + +export interface BuildVisibleJsonRowsOptions { + rootPath: string; + deep?: number; + collapsedNodeLength?: number; + expandedState: ReadonlyMap; +} diff --git a/packages/base/index.ts b/packages/base/index.ts index 0e0b36b..ec635a8 100644 --- a/packages/base/index.ts +++ b/packages/base/index.ts @@ -12,6 +12,7 @@ import WsMonitorToggle from "./item/ws-monitor-toggle.vue"; import SearchRow from "./data/search-row.vue"; import ListTable from "./data/list-table.vue"; import ListTableV2 from "./data/list-table-v2/list-table-v2.vue"; +import JsonView from "./data/json-view/json-view.vue"; import Infomation from "./data/infomation.vue"; import ModifyForm from "./data/modify-form.vue"; import Descriptions from "./data/descriptions.vue"; @@ -32,4 +33,4 @@ export { WsMonitorToggle, }; -export { SearchRow, ListTable, ListTableV2, ListTableDialog, Infomation, ModifyForm, Descriptions, TableAction }; +export { SearchRow, ListTable, ListTableV2, JsonView, ListTableDialog, Infomation, ModifyForm, Descriptions, TableAction };